Chương 30 · Luồng: đồng bộ hoá Chapter 30 · Threads: Thread Synchronization
Nhiều luồng dùng chung một bộ nhớ — làm sao chúng không giẫm lên nhau Nhiều luồng cùng một trí nhớ — làm sao để chúng không làm hỏng đồ của nhau Many threads sharing one memory — how to keep them from clobbering each other
Điều làm cho luồng (thread) mạnh mẽ cũng chính là điều khiến chúng nguy hiểm: mọi luồng trong một tiến trình dùng chung một không gian địa chỉ, nên có thể cùng đọc và ghi vào một biến toàn cục.17 Kerrisk mở chương bằng câu hỏi cốt lõi đó, rồi trao hai công cụ. Thứ nhất là mutex (loại trừ lẫn nhau), bảo đảm tại một thời điểm chỉ một luồng chạm vào một biến dùng chung — chặn cuộc đua cập-nhật-bị-mất (lost update).12 Thứ hai là biến điều kiện (condition variable), để một luồng ngủ chờ cho tới khi một luồng khác báo rằng trạng thái dùng chung đã đổi.15
Hãy hình dung nhiều người cùng viết lên một tấm bảng. Nếu ai cũng xoá và viết tuỳ ý cùng lúc, chữ sẽ thành một mớ hỗn độn. Chương này đưa ra hai luật chơi: một là chiếc chìa khoá (mutex) — ai cầm chìa mới được viết, viết xong trả chìa cho người khác; hai là chiếc chuông (biến điều kiện) — ai đang chờ thì ngồi yên, có thay đổi thì được rung chuông gọi dậy. Nhớ nhé: chìa khoá để không giẫm lên nhau, chuông để khỏi phải ngồi ngó hoài.
What makes threads powerful is also what makes them dangerous: every thread in a process shares one address space, so they can all read and write the same global variable.17 Kerrisk opens the chapter with that core problem, then hands over two tools. The first is the mutex (mutual exclusion), which guarantees that only one thread touches a shared variable at a time — blocking the lost-update race.12 The second is the condition variable, which lets a thread sleep until another thread signals that the shared state has changed.15
Điểm mấu chốt khiến đồng bộ hoá cần thiết: các thao tác tưởng là "một bước" trên ngôn ngữ cấp cao — ví dụ glob++ — thực ra gồm nhiều bước máy (đọc giá trị vào thanh ghi, tăng, ghi lại). Nếu hai luồng xen kẽ nhau giữa chừng chuỗi đọc–sửa–ghi ấy, một cập nhật sẽ bị đè mất. Mutex và biến điều kiện là hai mảnh ghép bổ sung nhau: mutex bảo vệ tính toàn vẹn của dữ liệu dùng chung, còn biến điều kiện giải quyết bài toán thời gian — chờ đợi hiệu quả thay vì quay vòng kiểm tra liên tục.1The crux that makes synchronization necessary: operations that look atomic in a high-level language — say glob++ — actually comprise several machine steps (load the value into a register, increment, store it back). If two threads interleave in the middle of that read-modify-write sequence, one update is overwritten and lost. Mutexes and condition variables are complementary pieces: the mutex protects the integrity of shared data, while the condition variable solves the timing problem — waiting efficiently instead of busy-polling.1
Luận đề của chương: vì các luồng dùng chung bộ nhớ, truy cập đồng thời vào một biến dùng chung sẽ phá hỏng dữ liệu; hai nguyên thủy đồng bộ của Pthreads — mutex để nối tiếp hoá truy cập và biến điều kiện để báo hiệu thay đổi trạng thái — là cặp công cụ nền tảng để lập trình đa luồng cho đúng.1
Ý chính: dùng chung trí nhớ thì tiện, nhưng cùng lúc sửa một chỗ thì hỏng. Giải pháp: một chiếc chìa khoá để thay phiên nhau, và một chiếc chuông để chờ nhau. Nhớ nhé: chia sẻ cần có luật — chìa khoá và chuông chính là luật đó.
The chapter's thesis: because threads share memory, concurrent access to a shared variable corrupts data; the two Pthreads synchronization primitives — the mutex to serialize access and the condition variable to signal state changes — are the foundational pair for writing correct multithreaded programs.1
Cụm khái niệm 01 Concept cluster 01
Dùng chung biến mà không khoá sẽ mất cập nhật — mutex nối tiếp hoá truy cập Cùng sửa một biến mà không thay phiên thì mất dữ liệu — chìa khoá giải quyết điều đó Sharing a variable without a lock loses updates — a mutex serializes access
Giả sử hai luồng cùng tăng một biến toàn cục bằng glob = glob + 1. Tuy một dòng, thao tác này gồm ba bước máy: đọc glob vào thanh ghi, tăng, rồi ghi lại. Nếu bộ lập lịch cắt ngang một luồng giữa bước đọc và bước ghi để chạy luồng kia, cả hai sẽ đọc cùng một giá trị cũ, cùng tăng, rồi cùng ghi — và một lần tăng biến mất. Đây là cuộc đua (race condition), và vùng mã truy cập tài nguyên dùng chung ấy gọi là vùng tới hạn (critical section).1 Để tránh, Pthreads cho ta mutex — một khoá chỉ cho đúng một luồng vào vùng tới hạn tại một thời điểm.12
Tưởng tượng hai người cùng đếm kẹo trong một cái lọ và cùng ghi số mới lên nhãn. Cả hai đọc "10", mỗi người thêm 1 trong đầu, rồi cùng viết "11" — đáng lẽ phải là "12"! Một lần thêm đã bị mất. Cách chữa: đặt một chiếc chìa khoá cạnh lọ; ai cầm chìa mới được đếm và ghi, xong trả chìa. Nhớ nhé: không thay phiên nhau thì sẽ có lần cộng bị nuốt mất.
Suppose two threads each increment a global via glob = glob + 1. Though one line, this is three machine steps: load glob into a register, increment, then store it back. If the scheduler preempts one thread between the load and the store to run the other, both read the same old value, both increment, and both store — and one increment vanishes. This is a race condition, and the region of code accessing the shared resource is the critical section.1 To prevent it, Pthreads gives us a mutex — a lock that admits exactly one thread into the critical section at a time.12
Định nghĩa. Một mutex có đúng hai trạng thái: đã khoá (locked) và chưa khoá (unlocked). Tại mọi thời điểm, nhiều nhất một luồng được giữ khoá. Luồng muốn vào vùng tới hạn gọi pthread_mutex_lock(); nếu mutex đang bị luồng khác giữ, lời gọi chặn (blocks) cho tới khi khoá được nhả. Xong việc, luồng gọi pthread_mutex_unlock() để trả khoá.12
Nói đơn giản. Mutex như một chiếc chìa khoá duy nhất cho một căn phòng. Muốn vào phòng (sửa dữ liệu chung) thì phải lấy được chìa; nếu người khác đang cầm, bạn đứng chờ. Ra khỏi phòng thì treo chìa lại cho người tiếp theo. Nhớ nhé: khoá trước khi sửa, mở sau khi xong.
Definition. A mutex has exactly two states: locked and unlocked. At any moment at most one thread may hold it locked. A thread wanting to enter the critical section calls pthread_mutex_lock(); if the mutex is held by another thread, the call blocks until the lock is released. When done, the thread calls pthread_mutex_unlock() to release it.12
#include <pthread.h>
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m); /* vào vùng tới hạn */
glob = glob + 1; /* dữ liệu dùng chung */
pthread_mutex_unlock(&m); /* rời vùng tới hạn */
/* trả 0 nếu thành công */
lock(&m) --> lấy chìa
glob = glob + 1 --> sửa đồ chung
unlock(&m) --> trả chìa
#include <pthread.h>
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&m); /* enter critical section */
glob = glob + 1; /* shared data */
pthread_mutex_unlock(&m); /* leave critical section */
/* returns 0 on success */
-
lost update
Cuộc đua cập-nhật-bị-mất: tổng cuối cùng nhỏ hơn mong đợi Mất cập nhật: cộng mãi mà con số cuối lại thiếu The lost-update race: the final total comes up short
Kerrisk minh hoạ bằng một chương trình (Listing 30-1,
thread_incr.c): hai luồng, mỗi luồng tăng cùng một biến toàn cục cả triệu lần mà không dùng mutex. Với số vòng lớn (ví dụ 10 triệu mỗi luồng), kết quả cuối thường nhỏ hơn hẳn con số mong đợi 20 triệu — bằng chứng rõ ràng rằng vô số lần tăng đã bị đè mất.1 Thêm một cặp khoá/mở khoá quanh phép tăng là biến đổi đó biến mất.1Trong ví dụ của sách, hai "người đếm" cùng cộng vào một con số hàng triệu lần mà không dùng chìa khoá. Đáng lẽ tổng là 20 triệu, nhưng con số cuối lại thiếu rất nhiều — vì nhiều lần cộng đã bị nuốt mất. Thêm chìa khoá vào là tổng lại đúng. Nhớ nhé: thiếu khoá thì con số cuối sẽ "bốc hơi" bớt.
Kerrisk illustrates this with a program (Listing 30-1,
thread_incr.c): two threads each increment the same global millions of times without a mutex. With a large loop count (say 10 million per thread), the final result is usually far below the expected 20 million — clear evidence that countless increments were overwritten.1 Add one lock/unlock pair around the increment and the corruption disappears.1Bạn gặp nó ở đâuWhere you meet it Bất cứ bộ đếm, danh sách liên kết hay cấu trúc dữ liệu nào bị nhiều luồng sửa — thiếu khoá là lỗi "chạy phần lớn thời gian nhưng thỉnh thoảng sai" khét tiếng khó tái hiện.1 Mọi bộ đếm hay danh sách bị nhiều luồng sửa — thiếu khoá là loại lỗi lúc đúng lúc sai, rất khó bắt. Any counter, linked list, or data structure modified by several threads — a missing lock is the notorious "works most of the time but occasionally wrong" bug that is hard to reproduce.1Vì sao kết quả không ổn địnhWhy the result is nondeterministic
Chạy chương trình thiếu khoá nhiều lần thường cho các con số sai khác nhau, vì nó phụ thuộc vào thời điểm chính xác mà bộ lập lịch cắt ngang — điều thay đổi theo tải máy. Chính tính không lặp lại này khiến cuộc đua nguy hiểm: nó có thể "ẩn mình" qua mọi lần thử nghiệm rồi mới lộ ra khi đã đưa vào vận hành.1Running the lock-less program repeatedly usually yields different wrong numbers, because it depends on the exact moment the scheduler preempts — which varies with machine load. This nonrepeatability is exactly what makes races dangerous: they can hide through every test run and surface only in production.1
Khoá thuộc về dữ liệu, không thuộc về mãThe lock belongs to the data
Một mutex không tự động gắn với biến nào; chính lập trình viên phải thiết lập quy ước rằng mọi truy cập vào một biến dùng chung đều phải diễn ra khi đang giữ một mutex nhất định. Quên quy ước đó ở một chỗ duy nhất là đủ để mở lại cuộc đua.12A mutex is not automatically bound to any variable; it is the programmer who must establish the convention that every access to a shared variable happens while holding a particular mutex. Forgetting that convention in a single place is enough to reopen the race.12
-
lock / unlock
Khoá chặn, mở nhả — và giữ vùng tới hạn thật ngắn Khoá thì phải chờ, mở thì buông — và đừng ôm chìa lâu Lock blocks, unlock releases — keep the critical section short
pthread_mutex_lock()chặn luồng gọi nếu mutex đang bị giữ; khi khoá được nhả, một trong các luồng đang chờ sẽ giành được nó.pthread_mutex_unlock()chỉ nên được gọi bởi luồng đang giữ khoá đó.9 Vì mọi luồng khác phải đứng chờ trong lúc khoá bị giữ, nguyên tắc vàng là giữ vùng tới hạn càng ngắn càng tốt — chỉ bao đúng phần chạm vào dữ liệu dùng chung, không bao các phép tính dài hay thao tác I/O.12Khi bạn cầm chìa, mọi người khác phải xếp hàng chờ. Vậy nên hãy làm việc trong phòng thật nhanh rồi trả chìa, đừng vào phòng rồi ngồi uống cà phê. Chỉ khoá đúng lúc đang sửa đồ chung, xong là mở ngay. Nhớ nhé: cầm chìa càng ngắn, mọi người càng đỡ phải chờ.
pthread_mutex_lock()blocks the calling thread if the mutex is held; when the lock is released, one of the waiting threads acquires it.pthread_mutex_unlock()should be called only by the thread that holds the lock.9 Since every other thread must wait while the lock is held, the golden rule is to keep the critical section as short as possible — wrapping only the part that touches shared data, not long computations or I/O.12Bạn gặp nó ở đâuWhere you meet it Khi hiệu năng đa luồng không tăng dù thêm lõi CPU — thủ phạm thường là một vùng tới hạn quá rộng khiến các luồng nối đuôi nhau chờ một khoá (tranh chấp khoá).1 Khi thêm lõi CPU mà chương trình không nhanh hơn — thường vì mọi luồng đang xếp hàng chờ chung một chìa. When adding CPU cores does not speed the program up — the culprit is often a critical section so wide that threads queue up behind one lock (lock contention).1 -
trylock / timedlock
Hai biến thể không-chặn và có-hạn-giờ Thử lấy chìa mà không đứng chờ mãi Two variants: nonblocking and timed
Ngoài lời gọi chặn, còn hai biến thể:
pthread_mutex_trylock()thử khoá nhưng trả về ngay với lỗiEBUSYnếu mutex đang bị giữ;pthread_mutex_timedlock()chặn nhưng bỏ cuộc vớiETIMEDOUTsau một mốc thời gian. Kerrisk lưu ý nên dùng dè sẻn: vòng lặp liên tục gọitrylock()(busy-wait) làm hao CPU, nên trong đa số trường hợp lời gọi chặn thông thường mới là lựa chọn đúng.12Đôi khi bạn không muốn đứng chờ chìa mãi.
trylockthử lấy chìa, nếu người khác đang cầm thì nó báo "bận" ngay để bạn đi làm việc khác.timedlockchờ nhưng chỉ chờ tới một lúc nhất định. Nhưng đừng cứ liên tục thử lấy chìa trong vòng lặp — vừa mệt máy vừa vô ích. Nhớ nhé: thường thì cứ chờ chìa là tốt nhất, đừng hỏi dồn dập.Besides the blocking call, there are two variants:
pthread_mutex_trylock()attempts the lock but returns immediately with errorEBUSYif the mutex is held;pthread_mutex_timedlock()blocks but gives up withETIMEDOUTafter a deadline. Kerrisk notes they should be used sparingly: a loop that repeatedly callstrylock()(a busy-wait) wastes CPU, so in most cases the ordinary blocking call is the right choice.12Bạn gặp nó ở đâuWhere you meet it Khi một luồng có việc hữu ích khác để làm nếu không lấy được khoá ngay — hoặc trong chiến lược "thử rồi lùi" (try-and-backoff) để phá bế tắc, sẽ bàn ở cụm sau.2 Khi luồng còn việc khác để làm nếu chưa lấy được chìa — hoặc để tránh bế tắc như nói ở phần sau. When a thread has other useful work to do if it cannot get the lock immediately — or in a try-and-backoff strategy to break deadlock, discussed in the next cluster.2
Cụm khái niệm 02 Concept cluster 02
Tạo mutex, tránh bế tắc, trả giá hiệu năng, và chọn đúng kiểu Làm ra chìa khoá, tránh kẹt, và các loại chìa Creating, avoiding deadlock, paying the cost, and choosing the type
Mutex có thể được khởi tạo theo hai cách. Với mutex cấp phát tĩnh và dùng thuộc tính mặc định, chỉ cần gán hằng PTHREAD_MUTEX_INITIALIZER. Với mutex cấp phát động — nằm trên vùng nhớ heap, nằm trong bộ nhớ chia sẻ, hoặc cần thuộc tính không mặc định — phải gọi pthread_mutex_init() lúc chạy, và khi không còn dùng thì giải phóng bằng pthread_mutex_destroy().13 Khi một chương trình dùng nhiều mutex, một nguy cơ mới xuất hiện: bế tắc (deadlock).1
Có hai cách làm ra chìa khoá. Cách đơn giản: với chìa "có sẵn chỗ để" và dùng kiểu thường, chỉ cần một dòng gán hằng. Cách linh hoạt: với chìa làm ra lúc chạy hoặc cần kiểu đặc biệt, phải gọi hàm tạo rồi nhớ hàm huỷ khi xong. Và khi có nhiều chìa cùng lúc, coi chừng kẹt cứng — hai người mỗi người ôm một chìa rồi chờ chìa của nhau mãi mãi. Nhớ nhé: chìa đơn giản thì gán hằng, chìa linh hoạt thì tạo rồi huỷ.
A mutex can be initialized two ways. For a statically allocated mutex with default attributes, simply assign the constant PTHREAD_MUTEX_INITIALIZER. For a dynamically allocated mutex — on the heap, in shared memory, or needing non-default attributes — you must call pthread_mutex_init() at run time, and free it with pthread_mutex_destroy() when no longer needed.13 When a program uses several mutexes, a new hazard appears: deadlock.1
Định nghĩa. Bế tắc là tình huống hai (hay nhiều) luồng kẹt cứng vĩnh viễn, mỗi luồng chờ một khoá mà luồng kia đang giữ. Trường hợp kinh điển: luồng A khoá mutex 1 rồi cố khoá mutex 2, trong khi luồng B khoá mutex 2 rồi cố khoá mutex 1 — không bên nào nhường, cả hai đứng im mãi mãi.1
Nói đơn giản. Bế tắc giống hai người ở hai đầu một cánh cửa hẹp, ai cũng khăng khăng "anh lùi trước thì tôi mới đi" — thế là cả hai đứng mãi. Trong lập trình, mỗi luồng ôm một chìa và chờ chìa kia. Nhớ nhé: ai cũng ôm một chìa rồi chờ chìa của người kia là kẹt cứng.
Definition. Deadlock is a situation where two (or more) threads are stuck forever, each waiting for a lock the other holds. The classic case: thread A locks mutex 1 then tries to lock mutex 2, while thread B locks mutex 2 then tries to lock mutex 1 — neither yields, and both stall permanently.1
/* tĩnh + mặc định */
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
/* động: heap / shm / thuộc tính riêng */
pthread_mutex_init(&m, NULL);
... dùng ...
pthread_mutex_destroy(&m);
tĩnh --> = PTHREAD_MUTEX_INITIALIZER
động --> init(&m, NULL)
... xong ...
destroy(&m)
/* static + default */
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
/* dynamic: heap / shm / custom attrs */
pthread_mutex_init(&m, NULL);
... use ...
pthread_mutex_destroy(&m);
-
deadlock / lock ordering
Phá bế tắc: khoá theo một thứ tự thống nhất Tránh kẹt: ai cũng lấy chìa theo cùng một thứ tự Breaking deadlock: lock in one agreed order
Cách phổ biến và đáng tin nhất để tránh bế tắc là xác lập một thứ tự khoá (lock hierarchy): mọi luồng luôn khoá các mutex theo cùng một trình tự quy định sẵn. Nếu luồng nào cũng khoá mutex 1 trước rồi mới tới mutex 2, vòng chờ tròn không thể hình thành.1 Một giải pháp khác là thử rồi lùi (try-and-backoff): dùng
pthread_mutex_trylock()cho các khoá sau, và nếu không lấy được thì nhả hết các khoá đang giữ rồi thử lại — tuy nhiên cách này kém hiệu quả hơn và chỉ nên dùng khi không thể áp đặt một thứ tự.12Mẹo đơn giản để không kẹt: quy ước mọi người luôn lấy các chìa theo cùng một thứ tự — chìa A trước, chìa B sau, không ai làm ngược. Khi đó không thể có cảnh hai người chờ nhau vòng tròn. Nhớ nhé: cùng một thứ tự lấy chìa thì không bao giờ kẹt vòng tròn.
The most common and reliable way to avoid deadlock is to establish a lock ordering (a lock hierarchy): every thread always locks the mutexes in the same prescribed order. If every thread locks mutex 1 before mutex 2, no circular wait can form.1 An alternative is try-and-backoff: use
pthread_mutex_trylock()for later locks and, if one cannot be acquired, release all held locks and retry — though this is less efficient and is used only when an ordering cannot be imposed.12Bạn gặp nó ở đâuWhere you meet it Khi chuyển tiền giữa hai tài khoản, mỗi tài khoản có khoá riêng — luôn khoá tài khoản có số hiệu nhỏ hơn trước, bất kể chiều chuyển, để không bao giờ bế tắc.1 Khi chuyển tiền giữa hai tài khoản có khoá riêng — luôn khoá theo một thứ tự cố định thì không bao giờ kẹt. When transferring between two accounts, each with its own lock — always lock the lower-numbered account first, whatever the direction, so deadlock never occurs.1Bốn điều kiện của bế tắcDeadlock's preconditions
Bế tắc chỉ xảy ra khi đồng thời có: loại trừ lẫn nhau, giữ-và-chờ, không thể tước đoạt khoá, và một vòng chờ tròn. Áp đặt thứ tự khoá chính là phá vỡ điều kiện vòng tròn — chỉ cần triệt một trong bốn điều kiện là đủ ngăn bế tắc.1Deadlock arises only when all of these hold at once: mutual exclusion, hold-and-wait, no preemption of a lock, and a circular wait. Imposing a lock ordering breaks the circular-wait condition — removing any one of the four conditions suffices to prevent deadlock.1
Tự khoá chính mìnhSelf-deadlock
Một dạng bế tắc đơn giản hơn: một luồng khoá một mutex rồi lại cố khoá chính mutex đó lần nữa. Với mutex kiểu thường, luồng sẽ kẹt cứng với chính mình; với mutex kiểu "kiểm lỗi" (errorcheck), lời gọi trả về lỗi
EDEADLKthay vì treo.124A simpler form of deadlock: a thread locks a mutex and then tries to lock that same mutex again. With a normal-type mutex the thread deadlocks against itself; with an errorcheck-type mutex, the call returns the errorEDEADLKrather than hanging.124 -
mutex performance
Mutex rẻ hơn bạn tưởng — nhờ futex Dùng chìa khoá không tốn nhiều như ta sợ Mutexes are cheaper than you fear — thanks to futexes
Kerrisk đo chi phí khoá/mở khoá bằng một vòng lặp chặt và kết luận rằng, dù mutex có thêm chi phí đo được, mỗi lần khoá rồi mở khoá là rẻ xét về trị tuyệt đối.1 Lý do: trên Linux, mutex được hiện thực bằng futex (fast user space mutex); trong trường hợp không tranh chấp — không luồng nào khác đang giữ khoá — việc khoá và mở khoá hoàn toàn diễn ra trong không gian người dùng, không cần một system call vào nhân. Chỉ khi có tranh chấp, nhân mới được gọi tới để đưa luồng vào giấc ngủ.18
Nhiều người sợ dùng khoá sẽ làm chương trình chậm hẳn. Thực ra, khi không có ai tranh chìa, việc lấy và trả chìa diễn ra rất nhanh ngay "tại chỗ", không phải phiền tới hệ điều hành. Chỉ khi thật sự có người đang giữ chìa thì mới cần hệ điều hành ra tay cho bạn ngủ chờ. Nhớ nhé: khoá lúc không ai tranh thì gần như miễn phí.
Kerrisk measures the lock/unlock cost with a tight loop and concludes that, although a mutex adds measurable overhead, each lock-then-unlock is cheap in absolute terms.1 The reason: on Linux, mutexes are implemented with futexes (fast user space mutexes); in the uncontended case — no other thread holds the lock — locking and unlocking happen entirely in user space with no system call into the kernel. Only under contention is the kernel invoked, to put a thread to sleep.18
Bạn gặp nó ở đâuWhere you meet it Khi cân nhắc "có nên khoá không vì sợ chậm" — câu trả lời gần như luôn là: cứ khoá cho đúng; chi phí thật nằm ở tranh chấp, không phải ở bản thân lời gọi khoá.1 Khi phân vân "khoá có làm chậm không" — cứ khoá cho đúng; cái đắt là lúc tranh chìa, không phải lúc lấy chìa. When you wonder "should I skip the lock to be fast" — the answer is almost always: lock correctly; the real cost is in contention, not in the lock call itself.1 -
NORMAL / ERRORCHECK / RECURSIVE
Ba kiểu mutex, ba cách ứng xử khi dùng sai Ba loại chìa khoá khác nhau Three mutex types, three ways to react to misuse
POSIX định nghĩa vài kiểu mutex, chọn qua thuộc tính lúc khởi tạo.4
PTHREAD_MUTEX_NORMALkhông kiểm lỗi: khoá lại mutex mình đang giữ gây bế tắc, còn mở khoá một mutex không phải của mình hay đang mở là hành vi không xác định.PTHREAD_MUTEX_ERRORCHECKkiểm mọi thao tác và trả lỗi (ví dụEDEADLK) thay vì treo — chậm hơn, hữu ích để gỡ lỗi.PTHREAD_MUTEX_RECURSIVEgiữ một bộ đếm khoá: cùng một luồng có thể khoá nhiều lần, và phải mở khoá đúng bấy nhiêu lần thì mutex mới thật sự được nhả.14Có ba loại chìa. Loại thường: nhanh nhất nhưng không bắt lỗi, dùng sai là kẹt hoặc hỏng âm thầm. Loại bắt lỗi: chậm hơn nhưng mách cho bạn biết khi làm sai, tiện khi đi tìm lỗi. Loại đệ quy: cho phép cùng một người khoá nhiều lần, nhưng phải mở đủ số lần đã khoá. Nhớ nhé: thường để chạy nhanh, bắt-lỗi để gỡ lỗi, đệ quy cho trường hợp đặc biệt.
POSIX defines several mutex types, chosen via an attribute at initialization.4
PTHREAD_MUTEX_NORMALdoes no error checking: relocking a mutex you hold deadlocks, and unlocking one you do not own or that is already unlocked is undefined behavior.PTHREAD_MUTEX_ERRORCHECKchecks every operation and returns an error (e.g.EDEADLK) instead of hanging — slower, useful for debugging.PTHREAD_MUTEX_RECURSIVEkeeps a lock count: the same thread may lock it repeatedly, and must unlock an equal number of times before the mutex is truly released.14Bạn gặp nó ở đâuWhere you meet it Khi một hàm đã giữ khoá lại gọi một hàm khác cũng cố giữ cùng khoá đó — mutex đệ quy cứu được, nhưng thường đó là dấu hiệu nên thiết kế lại thay vì phụ thuộc vào đệ quy.14 Khi một hàm đang giữ chìa lại gọi hàm khác cũng cần chính chìa đó — chìa đệ quy gỡ được, nhưng thường nên thiết kế lại. When a function already holding a lock calls another that also wants the same lock — a recursive mutex rescues this, but it is usually a sign to redesign rather than rely on recursion.14Kiểu mặc địnhThe default type
Kiểu mặc định là
PTHREAD_MUTEX_DEFAULT; trên Linux nó ứng xử như kiểu "thường". HằngPTHREAD_MUTEX_INITIALIZERtạo đúng một mutex kiểu mặc định này — nên phần lớn chương trình không cần chạm tới thuộc tính kiểu.14The default type isPTHREAD_MUTEX_DEFAULT; on Linux it behaves like the normal type. ThePTHREAD_MUTEX_INITIALIZERconstant creates exactly such a default mutex — so most programs never touch the type attribute at all.14Đặt kiểu ra saoHow to set the type
Muốn một kiểu khác, khởi tạo một đối tượng thuộc tính rồi gọi
pthread_mutexattr_settype(), truyền đối tượng ấy vàopthread_mutex_init(). Đây là một ví dụ của cơ chế thuộc tính tổng quát trong Pthreads.4For a different type, initialize an attributes object and callpthread_mutexattr_settype(), passing that object topthread_mutex_init(). This is one instance of the general Pthreads attributes mechanism.4
Cụm khái niệm 03 Concept cluster 03
Biến điều kiện: ngủ chờ cho tới khi trạng thái đổi — luôn đi với mutex và một vị từ Biến điều kiện: ngồi chờ rồi được chuông gọi dậy khi có thay đổi Condition variables: sleep until state changes — always with a mutex and a predicate
Một biến điều kiện cho phép một luồng ngủ chờ cho tới khi một luồng khác thông báo rằng "có chuyện vừa xảy ra" với một biến trạng thái dùng chung. Luồng chờ gọi pthread_cond_wait(); luồng gây ra thay đổi gọi pthread_cond_signal() (đánh thức ít nhất một luồng đang chờ) hoặc pthread_cond_broadcast() (đánh thức tất cả).156 Điểm cốt lõi — và là lý do biến điều kiện không bao giờ đứng một mình — là nó luôn đi kèm một mutex bảo vệ biến trạng thái, và điều kiện phải được kiểm trong một vòng lặp while.15
Biến điều kiện giống một chiếc chuông. Luồng nào chưa có việc thì ngồi yên chờ chuông, không phải liên tục hỏi "xong chưa?". Khi một luồng khác làm xong phần việc khiến tình hình thay đổi, nó rung chuông để gọi người đang chờ dậy. Chiếc chuông này luôn đi kèm một chiếc chìa khoá, và người vừa dậy phải tự kiểm lại "giờ đã thật sự tới lượt mình chưa?". Nhớ nhé: ngồi chờ chuông, dậy rồi vẫn phải kiểm lại cho chắc.
A condition variable lets a thread sleep until another thread announces that "something just happened" to a shared state variable. The waiting thread calls pthread_cond_wait(); the thread that caused the change calls pthread_cond_signal() (wakes at least one waiter) or pthread_cond_broadcast() (wakes all).156 The crux — and the reason a condition variable never stands alone — is that it is always paired with a mutex guarding the state variable, and the condition must be tested in a while loop.15
Định nghĩa. pthread_cond_wait(&cv, &m) làm ba việc như một thao tác nguyên tử không thể cắt rời: nhả mutex m, đưa luồng vào giấc ngủ, và khi được đánh thức thì khoá lại m trước khi trả về. Chính tính nguyên tử "nhả-và-ngủ" này bảo đảm không có tín hiệu nào lọt qua kẽ hở giữa lúc kiểm điều kiện và lúc bắt đầu chờ.15
Nói đơn giản. Khi bạn "ngồi chờ chuông", hệ thống tự động buông chìa khoá ra (để người khác làm việc được) rồi cho bạn ngủ — hai việc này làm liền một mạch. Lúc chuông reo và bạn tỉnh dậy, chìa khoá đã được trao lại vào tay bạn. Nhớ nhé: chờ chuông thì chìa tự buông, dậy thì chìa tự về tay.
Definition. pthread_cond_wait(&cv, &m) does three things as one atomic, indivisible operation: it releases mutex m, puts the thread to sleep, and on waking re-locks m before returning. This atomic "release-and-sleep" guarantees no signal slips through the gap between testing the condition and starting to wait.15
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
/* luồng chờ: */
pthread_mutex_lock(&m);
while (!sẵn_sàng) /* vị từ */
pthread_cond_wait(&cv, &m);
pthread_mutex_unlock(&m);
/* luồng báo: */
pthread_mutex_lock(&m);
sẵn_sàng = 1; pthread_cond_signal(&cv);
pthread_mutex_unlock(&m);
while (chưa tới lượt)
cond_wait(&cv, &m); /* ngủ chờ */
...
cond_signal(&cv); /* rung chuông */
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
/* waiter: */
pthread_mutex_lock(&m);
while (!ready) /* predicate */
pthread_cond_wait(&cv, &m);
pthread_mutex_unlock(&m);
/* signaler: */
pthread_mutex_lock(&m);
ready = 1; pthread_cond_signal(&cv);
pthread_mutex_unlock(&m);
-
pthread_cond_wait
Nhả-và-ngủ nguyên tử: vì sao không mất tín hiệu Buông chìa và ngủ cùng một lúc, không hụt chuông Atomic release-and-sleep: why no signal is lost
Nếu luồng phải tự nhả mutex rồi mới gọi lệnh ngủ ở hai bước riêng, sẽ có một kẽ hở: luồng khác có thể chen vào giữa hai bước đó, đổi trạng thái và rung tín hiệu trước khi luồng kia kịp ngủ — tín hiệu ấy rơi vào hư không, và luồng chờ sẽ ngủ mãi.
pthread_cond_wait()gộp nhả-khoá và ngủ thành một thao tác nguyên tử để đóng kẽ hở đó.15 Khi trả về, luồng lại đang giữ mutex, nên có thể kiểm tiếp biến trạng thái một cách an toàn.1Thử tưởng tượng nếu bạn buông chìa trước, rồi mới đi ngủ. Ngay giữa hai việc đó, người kia có thể rung chuông — mà bạn thì chưa ngủ nên không nghe thấy, rồi bạn lăn ra ngủ và chờ một tiếng chuông sẽ không bao giờ reo lại. Vì thế hệ thống làm cả hai việc liền một mạch để không bao giờ hụt chuông. Nhớ nhé: buông chìa và ngủ phải liền một nhịp, nếu không sẽ hụt chuông.
If a thread had to release the mutex and then issue a sleep as two separate steps, there would be a gap: another thread could slip between them, change the state, and signal before the first thread slept — that signal is lost, and the waiter sleeps forever.
pthread_cond_wait()fuses release-and-sleep into one atomic operation to close that gap.15 On return, the thread again holds the mutex, so it can safely re-test the state variable.1Bạn gặp nó ở đâuWhere you meet it Mẫu nhà sản xuất–người tiêu thụ (producer–consumer): người tiêu thụ chờ cho tới khi hàng đợi có việc, nhà sản xuất bỏ việc vào rồi báo hiệu.1 Mẫu "người làm – người nhận": người nhận chờ tới khi có việc, người làm bỏ việc vào rồi rung chuông. The producer–consumer pattern: the consumer waits until the queue has work, the producer enqueues it and signals.1Phiên bản có hạn giờThe timed variant
pthread_cond_timedwait()giốngpthread_cond_wait()nhưng bỏ cuộc với lỗiETIMEDOUTnếu tới một mốc thời gian tuyệt đối mà vẫn chưa được báo hiệu — hữu ích khi luồng không được phép chờ vô hạn.15pthread_cond_timedwait()is likepthread_cond_wait()but gives up with errorETIMEDOUTif an absolute deadline passes with no signal — useful when a thread may not wait indefinitely.15Báo hiệu khi giữ hay không giữ khoáSignaling with or without the lock
Có thể gọi
pthread_cond_signal()khi đang giữ hoặc không giữ mutex đi kèm. Giữ khoá rồi mới báo hiệu cho hành vi dễ đoán hơn; nhả khoá trước khi báo hiệu đôi khi nhanh hơn một chút vì tránh việc luồng vừa thức dậy lại lập tức bị chặn ở mutex.16You may callpthread_cond_signal()while holding or not holding the associated mutex. Signaling with the lock held is more predictable; signaling after releasing it is sometimes slightly faster, as it avoids a just-woken thread immediately blocking on the mutex.16 -
while, not if
Luôn kiểm vị từ trong while, không bao giờ trong if Dậy rồi phải kiểm lại bằng while, không phải if Always test the predicate in while, never in if
Kerrisk nhấn mạnh rằng lời gọi
pthread_cond_wait()phải nằm trong một vòngwhilekiểm lại vị từ (predicate — biểu thức về biến trạng thái), chứ không phải một câuif. Có nhiều lý do: một tín hiệubroadcastđánh thức nhiều luồng nhưng chỉ một luồng được tiếp tục; một luồng khác có thể đã thay đổi trạng thái lại trước khi luồng vừa dậy giành được mutex; và POSIX cho phép đánh thức giả (spurious wakeup) — luồng có thể tỉnh dậy dù không ai báo hiệu. Vòngwhilebuộc luồng kiểm lại vị từ sau mỗi lần dậy và ngủ tiếp nếu điều kiện chưa thật sự đúng.15Khi chuông reo và bạn tỉnh dậy, đừng vội cho rằng đã tới lượt mình. Có thể chuông gọi nhiều người cùng lúc mà chỉ một người được làm; có thể có người khác đã "cướp" việc trước; thậm chí đôi khi bạn tỉnh dậy mà chẳng ai gọi cả. Vì vậy, dậy rồi phải tự hỏi lại "đã thật sự tới lượt mình chưa?" — nếu chưa thì ngủ tiếp. Nhớ nhé: nghe chuông xong vẫn phải kiểm lại, chưa tới lượt thì ngủ tiếp.
Kerrisk stresses that the
pthread_cond_wait()call must sit inside awhileloop re-testing the predicate (an expression about the state variable), not anif. There are several reasons: abroadcastwakes many threads but only one may proceed; another thread may have changed the state back before the just-woken thread acquired the mutex; and POSIX permits spurious wakeups — a thread may wake with no signal at all. Thewhileloop forces the thread to re-test the predicate after each wake and go back to sleep if the condition is not truly satisfied.15Bạn gặp nó ở đâuWhere you meet it Mọi đoạn mã dùng biến điều kiện — dùngifthaywhilelà một trong những lỗi đa luồng phổ biến và khó bắt nhất, vì chương trình "thường chạy đúng" cho tới một lần đánh thức giả.5 Mọi chỗ dùng biến điều kiện — thay while bằng if là lỗi kinh điển, chạy đúng mãi rồi một ngày sai. Every piece of code using a condition variable — usingifinstead ofwhileis one of the commonest and hardest-to-catch threading bugs, since it "usually works" until one spurious wakeup.5 -
signal vs broadcast
Đánh thức một hay tất cả — và mutex tĩnh/động của biến điều kiện Gọi một người hay gọi tất cả, và cách tạo chiếc chuông Wake one or all — and static/dynamic condition variables
pthread_cond_signal()đánh thức ít nhất một luồng đang chờ;pthread_cond_broadcast()đánh thức tất cả. Dùngsignalkhi chỉ một luồng được lợi từ thay đổi và luồng nào cũng được (hiệu quả hơn); dùngbroadcastkhi thay đổi có thể cho nhiều luồng tiếp tục, hoặc các luồng chờ kiểm những vị từ khác nhau.16 Như mutex, biến điều kiện cũng có hai cách khởi tạo: tĩnh bằng hằngPTHREAD_COND_INITIALIZER, hoặc động bằngpthread_cond_init()và huỷ bằngpthread_cond_destroy().15Có hai cách rung chuông: gọi một người dậy (khi chỉ cần một người làm), hoặc gọi tất cả dậy (khi nhiều người có thể cùng làm, hoặc mỗi người chờ một việc khác nhau). Và chiếc chuông cũng làm được theo hai cách như chìa khoá: loại có sẵn thì gán hằng, loại linh hoạt thì tạo rồi huỷ. Nhớ nhé: gọi một người cho gọn, gọi tất cả khi ai cũng có thể làm.
pthread_cond_signal()wakes at least one waiting thread;pthread_cond_broadcast()wakes all. Usesignalwhen only one thread can benefit and any one will do (more efficient); usebroadcastwhen the change may let several threads proceed, or when waiters test different predicates.16 Like a mutex, a condition variable has two initialization paths: statically with the constantPTHREAD_COND_INITIALIZER, or dynamically withpthread_cond_init()and freed withpthread_cond_destroy().15Bạn gặp nó ở đâuWhere you meet it Dùngbroadcastcho khoá đọc–ghi hay hàng rào (barrier), nơi một thay đổi trạng thái có thể giải phóng nhiều luồng chờ cùng lúc;signaldùng cho hàng đợi một-người-lấy.6 Gọi tất cả khi một thay đổi mở cửa cho nhiều người cùng đi; gọi một người cho hàng đợi mỗi lần một việc. Usebroadcastfor read–write locks or barriers, where one state change may release many waiters at once; usesignalfor a one-taker queue.6An toàn khi thừa đánh thứcOver-waking is safe
Vì mọi luồng chờ đều kiểm vị từ trong vòng
while, dùngbroadcastở nơi lẽ ra chỉ cầnsignalvẫn đúng — chỉ kém hiệu quả hơn, vì các luồng thừa sẽ thức dậy, thấy vị từ chưa đúng rồi ngủ lại. Đây là lý do vòngwhilevàbroadcastbổ trợ cho nhau.16Because every waiter re-tests the predicate in awhileloop, usingbroadcastwheresignalwould suffice is still correct — merely less efficient, as the extra threads wake, find the predicate false, and sleep again. This is why thewhileloop andbroadcastcomplement each other.16Khi nào phải dùng khởi tạo độngWhen dynamic init is required
Giống mutex, biến điều kiện cấp phát động (trên heap hay trong bộ nhớ chia sẻ) hoặc cần thuộc tính không mặc định thì bắt buộc dùng
pthread_cond_init(); chỉ biến điều kiện tĩnh với thuộc tính mặc định mới dùng được hằng khởi tạo.15As with mutexes, a dynamically allocated condition variable (on the heap or in shared memory) or one needing non-default attributes must usepthread_cond_init(); only a static condition variable with default attributes can use the initializer constant.15
Ba ngộ nhận thường gặp Three common misconceptions
“Biến điều kiện ghi nhớ tín hiệu đã gửi” “A condition variable remembers a signal”
Ngộ nhận: gọi pthread_cond_signal() trước, luồng chờ sau vẫn nhận được tín hiệu.
Thực tế: biến điều kiện không có trạng thái; nếu không có luồng nào đang chờ lúc báo hiệu, tín hiệu bị mất. Chính vì vậy ta kiểm một vị từ trên biến trạng thái dùng chung, chứ không chỉ dựa vào tín hiệu.15
Myth: signal first and a thread that waits later still receives it.
Reality: a condition variable is stateless; if no thread is waiting when you signal, the signal is lost. That is exactly why you test a predicate on a shared state variable, not merely rely on the signal.15
“Chỉ luồng ghi mới cần khoá” “Only writers need the lock”
Ngộ nhận: luồng chỉ đọc một biến dùng chung thì đọc tự do, không cần mutex.
Thực tế: đọc trong khi một luồng khác đang ghi cũng là một cuộc đua; mọi truy cập vào một biến dùng chung — kể cả đọc — đều phải được nối tiếp hoá qua cùng một mutex.12
Myth: a thread that only reads a shared variable can read it freely, without a mutex.
Reality: reading while another thread writes is also a race; every access to a shared variable — reads included — must be serialized through the same mutex.12
“Nên dùng mutex đệ quy cho chắc” “Use a recursive mutex to be safe”
Ngộ nhận: mutex đệ quy hay kiểm-lỗi là lựa chọn mặc định an toàn hơn.
Thực tế: mutex kiểu thường (mặc định) nhanh nhất; kiểu kiểm-lỗi là công cụ gỡ lỗi (chậm hơn); kiểu đệ quy dành cho thiết kế đặc thù. Hãy ưu tiên mutex thường cộng một thiết kế đúng.14
Myth: a recursive or errorcheck mutex is the safer default choice.
Reality: the normal (default) mutex is fastest; the errorcheck type is a debugging aid (slower); the recursive type is for specific designs. Prefer a normal mutex plus a correct design.14
Tự kiểm Self-check
Năm câu hỏi để biết bạn đã nắm đồng bộ hoá luồng Năm câu hỏi kiểm tra nhanh Five questions to check you grasped thread synchronization
Trả lời được những câu này nghĩa là bạn đã nắm hai nguyên thủy đồng bộ của Pthreads — mutex và biến điều kiện — cùng những cái bẫy quanh chúng.
Nếu trả lời được, nghĩa là bạn đã hiểu chương này.
Answering these means you grasp the two Pthreads synchronization primitives — mutexes and condition variables — and the traps around them.
-
Vì sao
glob = glob + 1không an toàn khi hai luồng chạy đồng thời? Vùng tới hạn là gì?Gợi ý sâu hơn: vì sao kết quả của chương trình thiếu khoá lại khác nhau giữa các lần chạy?
-
Khi nào dùng
PTHREAD_MUTEX_INITIALIZERvà khi nào phải gọipthread_mutex_init()? Mối quan hệ vớipthread_mutex_destroy()?Gợi ý sâu hơn: vì sao mutex cấp phát động hay nằm trong bộ nhớ chia sẻ không dùng được hằng khởi tạo tĩnh?
-
Mô tả một kịch bản bế tắc giữa hai mutex. Thứ tự khoá phá bế tắc ra sao? Vì sao mutex vẫn được coi là rẻ?
Gợi ý sâu hơn: futex giúp lời gọi khoá/mở khoá tránh được điều gì trong trường hợp không tranh chấp?
-
pthread_cond_wait()làm những gì một cách nguyên tử? Vì sao biến điều kiện luôn đi kèm một mutex?Gợi ý sâu hơn: điều gì sẽ sai nếu việc nhả mutex và việc ngủ chờ được tách thành hai bước riêng?
-
Vì sao vị từ phải được kiểm trong vòng
whilechứ không phảiif? Khác biệt giữasignalvàbroadcast?Gợi ý sâu hơn: vì sao đánh thức giả (spurious wakeup) khiến
whiletrở thành bắt buộc chứ không chỉ là thói quen tốt?
Vì sao hai luồng cùng cộng vào một con số mà không thay phiên thì bị sai?
Chìa khoá (mutex) có mấy cách làm ra?
Bế tắc là gì, và mẹo đơn giản nào giúp tránh nó?
Chiếc chuông (biến điều kiện) dùng để làm gì?
Vì sao nghe chuông xong vẫn phải kiểm lại trước khi làm việc?
Why is
glob = glob + 1unsafe when two threads run concurrently? What is a critical section?When do you use
PTHREAD_MUTEX_INITIALIZERand when must you callpthread_mutex_init()? How doespthread_mutex_destroy()relate?Describe a deadlock scenario between two mutexes. How does lock ordering break it? Why are mutexes still considered cheap?
What does
pthread_cond_wait()do atomically? Why is a condition variable always paired with a mutex?Why must the predicate be tested in a
whileloop, not anif? What is the difference betweensignalandbroadcast?
Khép lại chương Closing the chapter
Luồng dùng chung bộ nhớ, nên đồng bộ hoá không phải thứ tuỳ chọn mà là điều kiện để đúng. Mutex nối tiếp hoá truy cập để không lần cập nhật nào bị đè mất; biến điều kiện cho ta chờ đợi mà không phí CPU, luôn đi kèm một mutex và một vị từ kiểm trong vòng while. Nắm vững cặp công cụ này, bạn đã có nền để đọc tiếp những cấu trúc đồng bộ cao cấp hơn được dựng trên chúng.1
Nhiều luồng dùng chung trí nhớ thì phải có luật. Chìa khoá để thay phiên nhau khỏi làm mất dữ liệu, và chuông để chờ nhau mà không phí sức máy. Hiểu hai thứ này là hiểu nền tảng của lập trình nhiều luồng. Nhớ nhé: chia sẻ cần luật — chìa khoá và chuông chính là luật đó.
Threads share memory, so synchronization is not optional but a condition for correctness. A mutex serializes access so no update is overwritten; a condition variable lets us wait without wasting CPU, always paired with a mutex and a predicate tested in a while loop. Master this pair and you have the groundwork to read the higher-level synchronization structures built on top of them.1