Đồng bộ hóa tiến trình (Process Synchronization)
1. Tổng quan
Đồng bộ hóa tiến trình (Process Synchronization) là kỹ thuật điều khiển nhằm phối hợp thứ tự thực thi và thời điểm truy cập khi nhiều tiến trình (hoặc luồng) truy cập đồng thời vào tài nguyên dùng chung, biến dùng chung, qua đó bảo đảm tính nhất quán (consistency) của dữ liệu và thu được kết quả đúng.
Khi đa chương trình và đa lõi trở nên phổ biến, việc nhiều luồng thực thi cùng chia sẻ một bộ nhớ, tệp, thiết bị trở thành chuyện thường ngày. Vấn đề là CPU xen kẽ (interleaving) các luồng thực thi một cách tùy ý ở cấp lệnh. Ngay cả count++, trông như một dòng, cũng được tách thành ba bước — "đọc từ bộ nhớ → tăng 1 trong thanh ghi → ghi vào bộ nhớ" — nên nếu hai luồng thực thi ba bước này xen lẫn nhau thì xảy ra mất cập nhật (lost update), trong đó cập nhật của một bên biến mất. Trạng thái bất thường mà kết quả thay đổi theo thứ tự thực thi (thời điểm) này chính là [[race-condition]], và đồng bộ hóa tiến trình bao hàm mọi cơ chế nhằm loại bỏ tận gốc tình trạng tranh chấp này.
Lý do đồng bộ hóa khó là vì khiếm khuyết bộc lộ một cách phi tất định (non-deterministic). Cùng một mã, tùy theo lập lịch, tải và số lõi, có lúc hoạt động đúng có lúc cho giá trị sai. Đây là Heisenbug điển hình không tái hiện khi kiểm thử nhưng bùng phát gián đoạn dưới mức đồng thời cao của môi trường vận hành, nên chi phí gỡ lỗi về sau rất lớn. Do đó, dưới góc nhìn Kỹ sư chuyên nghiệp, phải phán đoán chính xác ngay từ giai đoạn thiết kế "khi nào cần đồng bộ (nhận diện vùng tới hạn)" và "bảo đảm bằng công cụ nào và như thế nào (semaphore, mutex, monitor)".
Đồng bộ hóa tiến trình có hai mục đích lớn. Thứ nhất là loại trừ tương hỗ (mutual exclusion) — bảo đảm chỉ một luồng dùng tài nguyên dùng chung tại một thời điểm; thứ hai là điều khiển thứ tự thực thi (ordering/condition synchronization) — khớp quan hệ trước sau giữa các luồng, như "B tiêu thụ chỉ sau khi A sản xuất dữ liệu". Cái trước giữ tính toàn vẹn dữ liệu, cái sau giữ tính nhất quán logic của sự hợp tác (cooperation). Tuy nhiên nếu thiết kế đồng bộ sai, nó dẫn tới những sự cố khác như [[deadlock]] trong đó mỗi bên chờ bên kia vô hạn, hay đói tài nguyên (starvation) trong đó chỉ một luồng cụ thể cứ bị đẩy lùi; vì vậy đồng bộ hóa là bài toán cân bằng — thiết kế sao cho "không thừa cũng không thiếu".
Hai mục đích này thường còn được gọi là đồng bộ tranh chấp (competition synchronization) và đồng bộ hợp tác (cooperation synchronization). Đồng bộ tranh chấp làm trọng tài để chỉ một luồng dùng cùng một tài nguyên tại một thời điểm khi nhiều luồng tranh giành nó, loại trừ tương hỗ ứng với điều này; đồng bộ hợp tác làm cho các luồng mang vai trò khác nhau khớp trước sau của công việc để cùng đạt một mục tiêu, mà quan hệ sản xuất-tiêu thụ là điển hình. Hai loại đòi hỏi công cụ khác nhau: tranh chấp hợp với khóa (mutex), hợp tác hợp với cơ chế tín hiệu chờ điều kiện rồi đánh thức (biến điều kiện, semaphore). Trong hệ thống thực, hai loại xuất hiện trộn lẫn trong cùng một đoạn mã, nên nhận diện điểm nào là tranh chấp và điểm nào là hợp tác là điểm khởi đầu của thiết kế.
2. Bài toán vùng tới hạn và các điều kiện yêu cầu của đồng bộ hóa
Vùng mã truy cập tài nguyên dùng chung và thay đổi giá trị của nó gọi là vùng tới hạn (Critical Section), và cốt lõi của bài toán đồng thời nằm ở việc "bảo đảm tại bất kỳ thời điểm nào chỉ một luồng ở trong vùng tới hạn". Mã của mỗi luồng gồm vùng vào (entry section) để vào vùng tới hạn, vùng tới hạn, vùng ra (exit section) sau khi rời khỏi, và vùng còn lại (remainder).
graph TD
A["Đồng bộ hóa tiến trình"] --> B["Loại trừ tương hỗ(Mutual Exclusion)"]
A --> C["Điều khiển thứ tự(Condition Sync)"]
B --> D["Bài toán vùng tới hạn(Critical Section Problem)"]
D --> E["Vùng vào(Entry) → Vùng tới hạn → Vùng ra(Exit)"]
D --> F["3 điều kiện yêu cầu để giải"]
F --> G["Loại trừ tương hỗ(Mutual Exclusion)"]
F --> H["Tiến triển(Progress)"]
F --> I["Chờ giới hạn(Bounded Waiting)"]
Chuyện gì xảy ra nếu không bảo vệ vùng tới hạn trở nên rõ ràng qua ví dụ đơn giản hai luồng mỗi luồng tăng biến dùng chung count (giá trị ban đầu 10) lên 1. Nếu count++ bất khả phân thì kết quả phải là 12, nhưng nếu các lệnh xen kẽ thì cập nhật của một bên biến mất và thành 11 như dưới đây.
| Thời điểm | Luồng T1 | Luồng T2 | count (bộ nhớ) |
|---|---|---|---|
| t1 | read → 10 | 10 | |
| t2 | read → 10 | 10 | |
| t3 | 10+1 = 11 | 10 | |
| t4 | 10+1 = 11 | 10 | |
| t5 | write 11 | 11 | |
| t6 | write 11 | 11 |
Việc mất cập nhật này, khi count kết thúc bằng 11 thay vì 12, chính là lỗi điển hình mà đồng bộ hóa phải ngăn chặn. Nếu T2 bị loại trừ trong khi T1 ở trong vùng tới hạn thì kết quả luôn được bảo đảm là 12. Như vậy lời giải cho bài toán vùng tới hạn nhất thiết phải thỏa mãn cả ba điều kiện yêu cầu (định thức hóa của Dijkstra); phải hiểu rằng các điều kiện dưới đây không phải là một danh sách quy tắc đơn thuần mà là những cơ cấu, mỗi cơ cấu để ngăn một dạng thất bại cụ thể.
A. Loại trừ tương hỗ (Mutual Exclusion). Nếu một luồng đang thực thi trong vùng tới hạn thì không luồng nào khác được vào vùng tới hạn của mình. Nếu điều kiện này đổ vỡ, hai luồng cập nhật đồng thời biến dùng chung gây mất cập nhật, hỏng dữ liệu. Loại trừ tương hỗ là yêu cầu cơ bản và tối thiểu nhất của đồng bộ hóa, nhưng chỉ nó thì chưa đủ, vì áp dụng loại trừ tương hỗ quá mức có thể rơi vào bế tắc mà không ai vào được.
B. Tiến triển (Progress). Nếu vùng tới hạn đang trống và có luồng muốn vào thì việc chọn luồng sẽ vào vùng tới hạn không được trì hoãn vô hạn. Nghĩa là luồng hiện không dùng vùng tới hạn (đang ở vùng còn lại) không được cản trở việc vào của luồng khác. Điều kiện này ngăn sự kém hiệu quả và bế tắc kiểu "chỗ trống mà không ai vào được". Chính điểm mà các lời giải phần mềm thời kỳ đầu liên tục thất bại là điều kiện tiến triển này.
C. Chờ giới hạn (Bounded Waiting). Sau khi một luồng yêu cầu vào, phải có cận trên cho số lần các luồng khác có thể vào vùng tới hạn trước khi yêu cầu đó được chấp nhận. Không có điều kiện này, một luồng cụ thể cứ bị đẩy lùi và xảy ra đói tài nguyên trong đó nó không bao giờ có được tài nguyên mãi mãi. Nói cách khác, chờ giới hạn là điều kiện bảo đảm tính công bằng (fairness) của đồng bộ hóa.
Một nỗ lực cổ điển nhằm thỏa mãn cả ba điều kiện chỉ bằng phần mềm là thuật toán Peterson (Peterson's Algorithm). Hai tiến trình cùng dùng hai biến dùng chung flag[] (ý định vào) và turn (biến nhường), để mỗi bên giương cờ của mình rồi nhường lượt cho bên kia, qua đó thỏa mãn cả ba điều kiện. Logic cốt lõi nằm ở việc đánh dấu "tôi muốn vào (flag[i]=true)", rồi ghi bên kia vào turn để bày tỏ ý định nhường, và chỉ chờ khi bên kia đang muốn vào và đồng thời đến lượt bên kia. Dù hai luồng thử vào gần như đồng thời, biến turn chỉ cho một bên qua nên loại trừ tương hỗ không đổ vỡ; nếu bên kia không quan tâm thì có thể vào ngay, thỏa mãn tiến triển; và vì nhường lần lượt nên cũng thỏa mãn chờ giới hạn.
Tuy nhiên lời giải Peterson giới hạn ở hai tiến trình và, căn bản hơn, không hoạt động như vậy nếu thiếu rào cản bộ nhớ vì thực thi không theo thứ tự (out-of-order execution) và bộ nhớ đệm của CPU hiện đại. Nó cũng có sự kém hiệu quả chờ bận (busy waiting), trong đó CPU bị tiêu tốn liên tục trong khi chờ vào. Vì lý do này, thực tế dùng hỗ trợ phần cứng và công cụ đồng bộ ở cấp hệ điều hành.
3. Các kỹ thuật đồng bộ phần cứng và phần mềm
Nên hiểu công cụ đồng bộ như một cấu trúc phân tầng, trong đó hệ điều hành và runtime ngôn ngữ chồng các trừu tượng — semaphore, mutex, monitor — lên trên nền tảng phần cứng là các lệnh nguyên tử mà CPU cung cấp.
flowchart TD
HW["Lệnh nguyên tử phần cứng<br/>TAS · CAS · Fetch-and-Add"] --> OS["Công cụ đồng bộ OS/runtime"]
OS --> SEM["Semaphore<br/>phép toán P/V, bộ đếm số nguyên"]
OS --> MTX["Mutex<br/>khóa nhị phân có quyền sở hữu"]
OS --> MON["Monitor<br/>đóng gói cấp ngôn ngữ + biến điều kiện"]
SEM --> USE["Sản xuất-Tiêu thụ · Đọc-Ghi"]
MTX --> USE
MON --> USE
MTX --> PI["Lưu ý đảo ngược ưu tiên(Priority Inversion)"]
A. Kỹ thuật phần cứng (lệnh nguyên tử). CPU cung cấp các lệnh nguyên tử thực hiện "đọc-sửa-ghi" như một lệnh đơn bất khả phân. Tiêu biểu có TestAndSet (trả về giá trị đã đọc đồng thời đặt thành true), Compare-And-Swap(CAS) (chỉ thay bằng giá trị mới khi giá trị bộ nhớ bằng giá trị kỳ vọng), Fetch-and-Add. Vì không luồng nào khác có thể chen vào giữa chừng, chúng trở thành nền tảng sâu nhất để hiện thực khóa (lock). CAS đặc biệt là phép toán cốt lõi của cấu trúc dữ liệu không khóa (lock-free) vốn không dùng khóa nào cả, đạt được cập nhật nguyên tử mà không cần loại trừ tương hỗ bằng cách thử lại cho đến khi thành công. Tuy nhiên dùng trực tiếp kỹ thuật phần cứng có độ khó cao vì lập trình viên phải tự xử lý chờ bận và bài toán ABA (hiện tượng giá trị quay về A→B→A khiến CAS phán đoán sai).
Khóa đơn giản nhất làm từ lệnh nguyên tử là spinlock. Theo cách lặp TestAndSet và giữ CPU quay (spin) cho đến khi lấy được khóa, nó có lợi khi vùng tới hạn cực ngắn và thời gian chờ nhỏ hơn chi phí chuyển ngữ cảnh. Ngược lại, nếu vùng tới hạn dài thì spinlock lãng phí CPU, nên cách chặn (semaphore, mutex) đặt luồng ngủ vào hàng đợi chờ tốt hơn. Nghĩa là lựa chọn "chờ bận vs chặn" là một đánh đổi điển hình bị chi phối bởi độ dài vùng tới hạn và số lõi, và nhân tối ưu bằng mutex thích ứng (adaptive mutex) trộn cả hai, quay cho các lần chờ ngắn và chặn khi kéo dài.
B. Semaphore. Một biến đồng bộ kiểu số nguyên do Dijkstra đề xuất, chỉ truy cập bằng hai phép toán nguyên tử P (wait, giảm) và V (signal, tăng). P giảm 1 và vào nếu giá trị semaphore dương, và nếu bằng 0 hoặc nhỏ hơn thì đặt luồng vào hàng đợi chờ và cho ngủ (block). V tăng giá trị lên 1 và, nếu có luồng đang chờ, đánh thức một luồng. Nếu giá trị lớn nhất là 1 thì dùng như semaphore nhị phân (cho loại trừ tương hỗ), nếu từ 2 trở lên thì như semaphore đếm (để quản lý bể tài nguyên hữu hạn). Semaphore mạnh ở chỗ biểu diễn được cả loại trừ tương hỗ lẫn điều khiển thứ tự thực thi, nhưng bị phê phán là công cụ "dễ lạm dụng", vì lập trình viên phải tự tay khớp các cặp P/V khắp mã và bỏ sót một cái lập tức gây bế tắc hay vi phạm loại trừ tương hỗ.
Công dụng của semaphore đếm dễ hiểu qua bể kết nối cơ sở dữ liệu. Nếu bể có 10 kết nối thì khởi tạo semaphore bằng 10, luồng muốn mượn một kết nối giảm giá trị đi một bằng P rồi dùng, và trả về giá trị ban đầu bằng V khi trả lại. Khi giá trị về 0, luồng yêu cầu thứ mười một tự động ngủ trong hàng đợi chờ, và ngay khoảnh khắc ai đó trả kết nối, V đánh thức nó. Như vậy một semaphore hiện thực việc đếm tài nguyên "giới hạn mức sử dụng đồng thời đúng bằng N", và bể luồng, giới hạn tốc độ yêu cầu (rate limiting), điều khiển số kết nối đồng thời cũng theo cùng nguyên lý. Trái lại, nếu chỉ cần loại trừ tương hỗ thì mutex có khái niệm quyền sở hữu an toàn hơn semaphore.
C. Mutex. Một khóa chỉ nhằm mục đích loại trừ tương hỗ (Mutual Exclusion); trông giống semaphore nhị phân nhưng khác biệt quyết định là quyền sở hữu (ownership). Mutex chỉ có thể được mở bởi luồng đã khóa nó (chủ thể lock/unlock đồng nhất), trong khi semaphore cho phép bất kỳ luồng nào gọi V. Nhờ khái niệm quyền sở hữu này, mutex có thể theo vết chủ sở hữu, nên có thể giảm nhẹ [[priority-inversion]] bằng kế thừa ưu tiên (priority inheritance), vốn tạm thời nâng ưu tiên của luồng ưu tiên thấp đang giữ khóa. Còn có các biến thể như mutex đệ quy cho phép tái nhập thay vì bế tắc nếu luồng đã giữ khóa lại khóa thêm, và spinlock có lợi cho các lần chờ ngắn.
Quyền sở hữu cũng là cơ sở của tính an toàn. Mutex có thể coi việc một luồng không phải chủ sở hữu thử unlock là lỗi, nên về mặt cấu trúc ngăn được kiểu lạm dụng phổ biến của semaphore (loại trừ tương hỗ đổ vỡ do gọi V không khớp cặp). Ngược lại, vì quyền sở hữu, nảy sinh rủi ro "nếu luồng đang khóa chết trước khi kết thúc thì khóa không bao giờ được giải phóng", nên một thành ngữ tự động gắn việc giải phóng khóa với việc thoát khối, như lock_guard dựa trên RAII của C++ hay try-finally của Java, là thiết yếu. Như vậy, một lựa chọn thiết kế nhỏ (quyền sở hữu) quy định dây chuyền tới cả tính an toàn, khả năng phục hồi và thành ngữ sử dụng — đó là khác biệt bản chất với semaphore.
D. Monitor và biến điều kiện. Một trừu tượng đồng bộ cấp ngôn ngữ ở mức cao do Hoare và Brinch Hansen đề xuất nhằm giảm rủi ro lạm dụng semaphore. Nó đóng gói dữ liệu dùng chung và các thủ tục xử lý dữ liệu đó vào một monitor, và bên trong monitor, trình biên dịch và runtime tự động bảo đảm loại trừ tương hỗ để chỉ một luồng hoạt động tại bất kỳ thời điểm nào. Lập trình viên không cần viết P/V trực tiếp. Điều khiển thứ tự được biểu diễn bằng wait (nhường và chờ cho đến khi điều kiện được thỏa) và signal (đánh thức một luồng đang chờ) của biến điều kiện (condition variable). synchronized/wait/notify của Java và lock của C# là các hiện thực tiêu biểu của mô hình monitor. Khi dùng biến điều kiện, phải gọi wait bên trong vòng lặp while để điều kiện được kiểm tra lại sau khi thức, nhằm ngăn sự thức giả (spurious wakeup, điều kiện có thể lại sai dù đã thức) và việc bị cướp tín hiệu.
Nguyên tắc truyền tín hiệu của monitor có hai ngữ nghĩa, và khác biệt của chúng chi phối quy tắc lập trình thực tế. Kiểu Hoare (signal-and-wait) nhường monitor ngay cho luồng được đánh thức khi gọi signal, nên luồng được đánh thức được bảo đảm điều kiện là đúng, nhưng hiện thực phức tạp và chuyển ngữ cảnh thường xuyên. Ngược lại, kiểu Mesa (signal-and-continue) mà hầu hết ngôn ngữ thực tế áp dụng cho luồng gửi tín hiệu tiếp tục thực thi và luồng được đánh thức tranh chấp lại từ hàng đợi, nên điều kiện có thể lại thay đổi trong lúc đó. Chính vì vậy biến điều kiện của Java và POSIX phải kiểm tra lại điều kiện bằng while chứ không phải if, và vi phạm quy tắc này sinh ra lỗi "vào vùng tới hạn khi điều kiện sai". Đây là trường hợp tiêu biểu cho thấy dù trừu tượng ở mức cao, phải hiểu ngữ nghĩa bên trong mới dùng đúng được.
4. Các bài toán đồng bộ kinh điển và so sánh kỹ thuật
Có ba bài toán kinh điển thể hiện tinh túy của thiết kế đồng bộ. Mỗi bài toán đại diện cho một yêu cầu đồng bộ khác nhau.
A. Bài toán Sản xuất-Tiêu thụ (Bounded Buffer). Người sản xuất đặt dữ liệu vào bộ đệm hữu hạn và người tiêu thụ lấy ra. Khi bộ đệm đầy thì người sản xuất phải chờ, khi rỗng thì người tiêu thụ phải chờ. Nó được giải gọn gàng bằng ba semaphore — số ô trống (empty), số ô đầy (full), và một cho loại trừ tương hỗ (mutex). Bài toán này là nguyên mẫu cần cả loại trừ tương hỗ lẫn điều khiển thứ tự cùng lúc, và thiết kế áp lực ngược (backpressure) của hàng đợi thông điệp, đường ống xử lý luồng về bản chất là cấu trúc này. Ví dụ, khi luồng thu thập (người sản xuất) trong hệ thống thu thập nhật ký tuôn ra hàng chục nghìn mục mỗi giây, việc điều chỉnh kích thước bộ đệm và số người tiêu thụ để khớp thông lượng mà không nổ bộ nhớ chính là ứng dụng thực tế của mô hình này.
B. Bài toán Đọc-Ghi (Readers-Writers). Nhiều người đọc có thể đọc đồng thời từ dữ liệu dùng chung, nhưng trong khi người ghi đang ghi thì cả người đọc lẫn người ghi đều không được truy cập. Ở điểm đọc là dùng chung, ghi là độc quyền, nó tinh vi hơn loại trừ tương hỗ đơn thuần. Chính sách ưu tiên người đọc nâng thông lượng đọc nhưng người ghi có thể bị đói, còn chính sách ưu tiên người ghi thì ngược lại. Sản phẩm thực tế của đánh đổi này là khóa chia sẻ/khóa độc quyền của cơ sở dữ liệu và ReadWriteLock cho hiệu năng đọc, và phát triển xa hơn thành điều khiển đồng thời đa phiên bản (MVCC) giữ cho người ghi không chặn người đọc.
Trong dịch vụ thực tế, cấu trúc đọc-ghi nổi bật ở bộ nhớ đệm, kho cấu hình, kho phiên nơi tỷ trọng đọc cao áp đảo. Ví dụ, trong hệ thống đọc một giá trị cấu hình hàng trăm nghìn lần mỗi giây nhưng cập nhật chỉ vài lần một ngày, dùng mutex đơn thuần khiến các lần đọc chặn lẫn nhau không cần thiết và thông lượng sụt mạnh. Ở đây, cho người đọc đi qua đồng thời bằng ReadWriteLock nâng thông lượng đọc lên hàng chục lần. Tuy nhiên nếu ghi trở nên thường xuyên, chi phí quản lý của khóa đọc-ghi có thể vượt mutex đơn thuần, nên phán đoán thiết kế quan trọng là phải chọn dựa trên đo đạc tỷ lệ đọc/ghi.
Bài toán đọc-ghi cũng cho thấy chính sách công bằng gắn trực tiếp với hiệu năng. Khi yêu cầu đọc và ghi đến xen lẫn, phân bố thông lượng và độ trễ thay đổi lớn tùy theo cho ai qua trước (ưu tiên người đọc, ưu tiên người ghi, xếp hàng công bằng), nên phải định chính sách khi xét cùng lúc đặc tính đọc/ghi của dịch vụ và yêu cầu cận trên độ trễ. Điều này làm lộ ra rằng đồng bộ hóa không chỉ là vấn đề "tính đúng đắn" mà là nhiệm vụ thiết kế đồng thời các thuộc tính chất lượng (hiệu năng, công bằng).
C. Bài toán Bữa ăn của các triết gia (Dining Philosophers). Năm triết gia mỗi người phải cầm cả hai chiếc nĩa hai bên mới ăn được, nhưng nếu tất cả cầm nĩa trái và chờ nĩa phải thì rơi vào chờ vòng tròn (bế tắc). Bài toán này cô đọng cho thấy khi đồng bộ bị làm sai thì dẫn tới [[deadlock]] như thế nào. Các lời giải làm cho thứ tự thu tài nguyên bất đối xứng (người lẻ cầm trái trước, người chẵn cầm phải trước), giới hạn số người ăn đồng thời ở N−1, hoặc cho cầm cả hai nĩa một cách nguyên tử, đều phá một trong bốn điều kiện của bế tắc. Trông như bài toán đồ chơi trên bề mặt, nhưng cấu trúc của nó hoàn toàn giống các tình huống thực tế "phải nắm nhiều tài nguyên logic cùng lúc", như cạn bể kết nối hay bể luồng chờ lẫn nhau, nên cùng nguyên lý giải (sắp thứ tự tài nguyên, giới hạn người dùng đồng thời) áp dụng trực tiếp.
Như ba bài toán cho thấy, lựa chọn công cụ đồng bộ thay đổi tùy theo "phải bảo đảm cái gì". Bảng dưới đây sắp xếp đặc tính của các công cụ tiêu biểu, nhưng vì sao mỗi công cụ hợp với công dụng đó bắt nguồn từ khác biệt theo ba trục: quyền sở hữu, mức trừu tượng và công bằng.
| Công cụ | Quyền sở hữu | Mức trừu tượng | Công dụng chính | Hiện thực tiêu biểu |
|---|---|---|---|---|
| Lệnh nguyên tử(CAS) | Không | Phần cứng | Cấu trúc dữ liệu không khóa | compareAndSet, atomic |
| Semaphore | Không | OS mức thấp | Điều khiển thứ tự, bể tài nguyên | POSIX sem_t |
| Mutex | Có | OS/runtime | Loại trừ tương hỗ | pthread_mutex, std::mutex |
| Monitor | (ngôn ngữ quản lý) | Ngôn ngữ mức cao | Loại trừ tương hỗ + chờ điều kiện | Java synchronized |
Hàm ý thực tế quan trọng nhất trong bảng này là có hay không quyền sở hữu. Vì semaphore không có chủ sở hữu, nó tự nhiên cho mẫu điều khiển thứ tự như "một luồng chờ và luồng khác đánh thức", nhưng dùng cho loại trừ tương hỗ thì luồng cần giải phóng có thể khác luồng đã khóa nên lỗi dễ ẩn náu. Trái lại, vì mutex có quyền sở hữu, nó an toàn cho loại trừ tương hỗ và cho phép các tính năng nâng cao như kế thừa ưu tiên, nhưng điều khiển thứ tự cần thêm biến điều kiện. Vì thế ngôn ngữ hiện đại khuyến nghị phân chia vai trò: "mutex/monitor cho loại trừ tương hỗ, biến điều kiện cho điều khiển thứ tự, semaphore cho đếm tài nguyên".
Khác biệt về mức trừu tượng cũng phân chia lựa chọn thực tế. Lệnh nguyên tử nhanh nhất nhưng tiềm ẩn lỗi cao vì lập trình viên phải chịu trách nhiệm trực tiếp tới cả ABA và tính khả kiến bộ nhớ, còn monitor an toàn nhất nhưng bị ràng vào hỗ trợ ngôn ngữ nên khó điều khiển tinh. Semaphore và mutex nằm ở giữa, với hệ điều hành quản lý việc chặn và hàng đợi chờ. Nhìn chung, chiến lược "an toàn trước, tối ưu có chọn lọc" được khuyến nghị — bắt đầu ở mức trừu tượng cao nhất (monitor, bộ sưu tập đồng thời) và chỉ hạ xuống công cụ mức thấp tại các điểm nóng mà lập hồ sơ hiệu năng đã xác nhận nút thắt cổ chai — vì tối ưu mức thấp vội vàng phải trả cái giá lớn là các lỗi đồng thời bất khả tái hiện.
5. Chuyên sâu: Xu hướng mới nhất và ứng dụng thực tế
A. Đồng thời không khóa/không chờ (Lock-free/Wait-free). Khi đa lõi mở rộng tới hàng chục lõi và tranh chấp khóa (lock contention) trở thành nút thắt của khả năng mở rộng, cấu trúc dữ liệu không khóa đạt được đồng thời không cần khóa bằng cách áp dụng CAS lặp lại thu hút sự chú ý. ConcurrentHashMap·AtomicInteger của Java và std::atomic của C++ là tiêu biểu. Không khóa cho bảo đảm tiến triển mạnh là toàn thể không dừng dù một luồng dừng, nhưng vẫn là lĩnh vực độ khó cao vì thiết kế bài toán ABA và thu hồi bộ nhớ (con trỏ hazard, RCU) hóc búa. Tùy độ mạnh của bảo đảm tiến triển, nó chia thành không khóa, nơi "ít nhất một luồng chắc chắn tiến tới", và không chờ (wait-free) mạnh hơn, nơi "mọi luồng tiến tới trong thời gian hữu hạn"; cái sau dùng để bảo đảm cận trên độ trễ trong hệ thống thời gian thực nhưng chi phí hiện thực rất lớn.
B. Mô hình bộ nhớ và rào cản bộ nhớ. CPU và trình biên dịch hiện đại sắp xếp lại thứ tự truy cập bộ nhớ vì hiệu năng. Do đó công cụ đồng bộ phải được định nghĩa cùng rào cản bộ nhớ (memory barrier) "bảo đảm tính khả kiến bộ nhớ tại ranh giới vùng tới hạn". volatile và quan hệ happens-before của Java, memory_order của C++11 xử lý điều này một cách tường minh. Vì một số lượng đáng kể lỗi đồng bộ bắt nguồn từ "khóa đã đặt đúng nhưng bỏ sót tính khả kiến bộ nhớ", ở mức Kỹ sư chuyên nghiệp phải luận được không chỉ loại trừ tương hỗ mà cả tính khả kiến (visibility).
Trường hợp điển hình của việc bỏ sót tính khả kiến là trục trặc của khóa kiểm tra kép (double-checked locking). Thành ngữ này, kiểm tra điều kiện một lần trước khi khóa và một lần sau vì hiệu năng, được dùng rộng rãi cho khởi tạo singleton, nhưng do sắp xếp lại việc tạo đối tượng và gán tham chiếu đã sinh ra lỗi chí mạng khi một đối tượng tạo dở — "tham chiếu thì thấy được nhưng các trường bên trong chưa được khởi tạo" — bị phơi ra cho các luồng khác. Java giải quyết bằng khai báo volatile (dưới mô hình bộ nhớ được tăng cường từ 1.5), còn C++ bằng std::atomic và memory_order phù hợp. Trường hợp này cho thấy rõ dù tuân thủ loại trừ tương hỗ hoàn hảo, nếu bỏ lỡ tính khả kiến bộ nhớ thì đồng bộ hóa đổ vỡ.
C. Bất đồng bộ/không chặn và đồng bộ phân tán. Dịch vụ I/O quy mô lớn biểu diễn đồng thời bằng async/await, future, kênh thay vì chặn luồng, giảm chi phí chuyển ngữ cảnh. Dưới triết lý "đừng giao tiếp bằng chia sẻ bộ nhớ; hãy chia sẻ bộ nhớ bằng giao tiếp", ngôn ngữ Go đặt đồng bộ dựa trên kênh làm công dân hạng nhất, còn Rust chặn tranh chấp dữ liệu tại thời điểm biên dịch bằng quyền sở hữu và bộ kiểm tra mượn. Trong khi đó, loại trừ tương hỗ trải qua nhiều nút không thể giải bằng mutex của một máy đơn, nên dùng khóa phân tán như Redis Redlock, ZooKeeper, etcd. Khóa phân tán phải gia cố tính an toàn bằng cho thuê (lease) và token rào (fencing token) vì độ trễ mạng và sự cố nút.
D. Liên hệ đề đã ra và hướng ra đề dự kiến. Trong kỳ thi Kỹ sư chuyên nghiệp, đồng bộ hóa được ra đề đều đặn dưới các dạng như mô tả ba điều kiện yêu cầu của bài toán vùng tới hạn, so sánh khác biệt giữa semaphore và mutex (đặc biệt quyền sở hữu), giải thích hoạt động của monitor và biến điều kiện, thiết kế lời giải semaphore cho bài toán sản xuất-tiêu thụ và đọc-ghi. Về chiến lược dựng bài, một bài luận ở mức chuyên sâu, khác biệt sẽ hình thành nếu: ① trình bày tình trạng tranh chấp → nhu cầu đồng bộ bằng ví dụ mất cập nhật ở phần tổng quan, ② giải thích ba điều kiện vùng tới hạn mỗi điều kiện gắn với dạng thất bại của nó, ③ sắp xếp semaphore, mutex, monitor theo trục quyền sở hữu và mức trừu tượng cùng với bảng so sánh, ④ chọn một bài toán kinh điển và triển khai mã lời giải bằng mã giả, và ⑤ ở kết luận nối sự chuyển dịch sang bế tắc, đói tài nguyên, đảo ngược ưu tiên với việc mở rộng sang môi trường phân tán và đa lõi. Học [[deadlock]], [[race-condition]], [[priority-inversion]] gộp lại có thể bao phủ rộng phạm vi ra đề.
6. Cân nhắc và hàm ý (góc nhìn Kỹ sư chuyên nghiệp)
Đồng bộ hóa là đánh đổi giữa tính đúng đắn và hiệu năng. Lấy phạm vi khóa (critical section) rộng thì an toàn nhưng giết chết song song, còn lấy hẹp thì nâng hiệu năng nhưng tăng rủi ro lỗi. Phải thiết kế độ mịn khóa (lock granularity) khớp với độ tranh chấp tài nguyên, số lõi và yêu cầu thời gian đáp ứng, và áp dụng tối ưu theo tầng như làm sống lại song song đọc bằng
ReadWriteLockhoặc MVCC khi tỷ trọng đọc cao.Khiếm khuyết đồng bộ chuyển dịch sang bế tắc, đói tài nguyên, đảo ngược ưu tiên. Khóa đặt cho loại trừ tương hỗ mời gọi [[deadlock]]; lơ là công bằng gây đói tài nguyên; và trong hệ thống thời gian thực xảy ra [[priority-inversion]]. Do đó thiết kế đồng bộ phải tiếp cận không phải như một kỹ thuật đơn lẻ mà như một thiết kế tích hợp xét cùng lúc sắp thứ tự khóa, timeout, kế thừa ưu tiên và lão hóa (aging).
Ưu tiên trừu tượng mức cao nhưng phải hiểu nguyên lý. Dùng các công cụ mức cao đã được kiểm chứng như monitor,
ReadWriteLock, bộ sưu tập đồng thời giảm lạm dụng tốt hơn xử lýP/Vcủa semaphore bằng tay. Nhưng phải hiểu rằng bên trong chúng rốt cuộc đứng trên lệnh nguyên tử và rào cản bộ nhớ, mới chẩn đoán được nút thắt hiệu năng và các lỗi khả kiến tinh vi. Đặc biệt quy tắc phải kiểm tra lại biến điều kiện bằngwhiledo ngữ nghĩa Mesa của monitor cho thấy hiểu hoạt động bên trong là thiết yếu ngay cả khi dùng trừu tượng.Khả năng quan sát và kiểm chứng tự động là cốt lõi của vận hành. Vì lỗi đồng bộ khó tái hiện, cách tiếp cận giám sát thời gian chờ khóa, số lần tranh chấp, bản kết xuất luồng để bắt sớm, và chứng minh sự vắng mặt của tình trạng tranh chấp tiềm ẩn và tính không bế tắc trước khi vận hành bằng
ThreadSanitizer, phân tích tĩnh, kiểm thử đồng thời và kiểm chứng hình thức (TLA+, model checking) được khuyến nghị trong hệ thống độ tin cậy cao.Trong môi trường phân tán và đám mây, ranh giới của đồng bộ hóa mở rộng. Vượt khỏi mutex của một tiến trình đơn, khóa phân tán, tính lũy đẳng (idempotency), điều khiển đồng thời lạc quan trở thành phương tiện đồng bộ mới. Giả định phân vùng mạng và sự cố cục bộ, giảm chính trạng thái khả biến dùng chung nếu có thể (tính bất biến, event sourcing) và thiết kế bằng phối hợp dựa trên giao tiếp nâng khả năng phục hồi.
Phải lấy tương quan giữa độ mịn đồng bộ và khả năng mở rộng làm chỉ số thiết kế. Số lõi càng tăng, khóa thô (coarse-grained lock) càng mất nhanh lợi ích song song hóa theo định luật Amdahl. Do đó nên chuyển sang các cấu trúc giảm chính tranh chấp — chia sọc khóa (lock striping), đọc-sao-cập nhật (RCU), khóa lạc quan, bộ đếm phân mảnh — và thực hiện cách tiếp cận dựa trên dữ liệu định lượng điểm tranh chấp qua lập hồ sơ hiệu năng và điều chỉnh độ mịn từng bước.
Thiết kế đồng bộ phải bảo đảm độ tin cậy bằng chứng minh chứ không phải kiểm thử. Khiếm khuyết đồng thời chỉ bộc lộ dưới thời điểm nhất định và hiếm khi tái hiện bằng kiểm thử đơn vị thông thường. Do đó, thực hành tốt nhất cho hệ thống độ tin cậy cao là áp dụng từng bước các khung kiểm thử đồng thời hỗ trợ tái hiện tất định,
ThreadSanitizerbắt tranh chấp dữ liệu tại thời gian chạy, và model checking (TLA+, SPIN) chứng minh toán học tính không bế tắc, tính an toàn và tính sống (liveness) của giao thức đồng thời. Cốt lõi là bảo đảm ở mức "đã cho thấy tình trạng tranh chấp là bất khả dưới các điều kiện này", chứ không phải "nó đã qua các bài kiểm thử".
Tài liệu tham khảo
- Silberschatz, Galvin, Gagne, "Operating System Concepts" (chương Synchronization)
- E. W. Dijkstra, "Cooperating Sequential Processes" (nguồn gốc về semaphore và vùng tới hạn)
- C. A. R. Hoare, "Monitors: An Operating System Structuring Concept", CACM, 1974
- POSIX Threads Programming: https://hpc-tutorials.llnl.gov/posix/
- Oracle Java Tutorials — Concurrency: https://docs.oracle.com/javase/tutorial/essential/concurrency/
- The Go Memory Model: https://go.dev/ref/mem
Tóm tắt một câu: Đồng bộ hóa tiến trình định thức hóa bài toán vùng tới hạn bằng ba điều kiện loại trừ tương hỗ·tiến triển·chờ giới hạn, và chồng semaphore·mutex·monitor lên trên lệnh nguyên tử để bảo đảm tính nhất quán và thứ tự thực thi của tài nguyên dùng chung, đồng thời phải được thiết kế một cách tổng thể — bao trùm cả sự chuyển dịch sang bế tắc·đói tài nguyên·đảo ngược ưu tiên lẫn tính khả kiến bộ nhớ và khóa phân tán — như là kỹ thuật cốt lõi của đồng thời.