In a multi-programming environment, multiple processes often run concurrently, and they might share data or resources. Process synchronization is the mechanism to manage the execution of these cooperating processes to ensure orderly access to shared resources and maintain data consistency. Without proper synchronization, concurrent access can lead to unpredictable outcomes and errors.
Process Synchronization refers to the coordination of multiple processes (or threads) that are executing concurrently and potentially accessing shared data or resources. The primary goal is to control the sequence of their execution to prevent conflicts and ensure that the outcome of their concurrent operations is correct and predictable, as if they were executed in some sequential order.
Synchronization is crucial for several reasons:
Processes can be categorized based on their interaction:
Process synchronization is primarily concerned with managing the interactions of cooperating processes.
Synchronization needs can arise from two main scenarios:
A fundamental challenge in concurrent programming is managing access to shared resources. The part of a program where a shared resource is accessed is known as the critical section.
A Critical Section (CS) is a segment of code in a process where shared resources (like common variables, data structures, files, etc.) are accessed and potentially modified. It's "critical" because if multiple processes execute their respective critical sections concurrently, it can lead to data corruption or inconsistent state due to uncoordinated modifications.
The general structure of a process involving a critical section is:
do {
// Entry Section: Code to request permission to enter CS
// --- CRITICAL SECTION ---
// Access shared resources
// --- END CRITICAL SECTION ---
// Exit Section: Code to release CS
// Remainder Section: Other non-critical code
} while (true);
A Race Condition is an undesirable situation that occurs when a device or system attempts to perform two or more operations at the same time, but because of the nature of the device or system, the operations must be done in the proper sequence to be done correctly. In the context of processes, it occurs when multiple processes access and manipulate shared data concurrently, and the final outcome depends on the particular order in which their instructions are interleaved by the scheduler.
Consider two processes, P1 and P2, both trying to increment a shared counter variable, count
, initialized to 5.
The high-level instruction count++
might translate to the following machine instructions:
LOAD R, count
(Load the value of count
into a register R)INCREMENT R
(Increment the register R)STORE R, count
(Store the new value from R back to count
)If P1 and P2 execute concurrently, their instructions might interleave like this:
Step | P1 Instruction | P2 Instruction | Register P1 (R1) | Register P2 (R2) | count Value |
Comment |
---|---|---|---|---|---|---|
1 | LOAD R1, count | 5 | - | 5 | P1 loads count (5) | |
2 | LOAD R2, count | 5 | 5 | 5 | P2 loads count (5) before P1 updates it | |
3 | INCREMENT R1 | 6 | 5 | 5 | P1 increments its local copy | |
4 | INCREMENT R2 | 6 | 6 | 5 | P2 increments its local copy | |
5 | STORE R1, count | 6 | 6 | 6 | P1 stores its result (6) | |
6 | STORE R2, count | 6 | 6 | 6 | P2 stores its result (6), overwriting P1's update. |
Expected Result: count
should be 7 (5 + 1 + 1).
Actual Result (due to race condition): count
is 6.
This demonstrates how unsynchronized access to shared data can lead to incorrect results. The critical section is the code segment count++
.
Any solution to the critical section problem must satisfy the following three fundamental requirements:
If a process is executing in its critical section, then no other process can be executing in its critical section for the same shared resource. This is the primary goal – to prevent simultaneous access.
If no process is executing in its critical section and some processes wish to enter their critical sections, then only those processes that are not executing in their remainder sections can participate in the decision of which will enter its critical section next. Furthermore, this selection cannot be postponed indefinitely. This means:
There must exist a bound on the number of times that other processes are allowed to enter their critical sections after a process has made a request to enter its critical section and before that request is granted. This ensures that no process waits indefinitely to enter its critical section (i.e., it prevents starvation).
Additionally, a good solution should be efficient and, if possible, work for any number of processes, not just two.
Solutions to the critical section problem can be broadly categorized based on how a process waits when it cannot enter its critical section:
In busy-waiting solutions, when a process attempts to enter its critical section but finds it occupied, it continuously executes a loop, repeatedly checking if the critical section has become available. This "spinning" consumes CPU cycles without doing productive work. These are also known as spinlocks.
In blocking solutions, when a process cannot enter its critical section, it is moved from the Running
state to a Waiting
(or Blocked
) state by the operating system. It yields the CPU, allowing other processes to run. When the critical section becomes free, the blocked process is woken up (moved to the Ready
state) and can eventually re-enter the Running
state to attempt entry again.
Primitives like semaphores and monitors typically use blocking.
These solutions rely purely on software algorithms and shared variables, without special hardware instructions. They are often complex to design correctly and typically work for a limited number of processes.
One of the simplest ideas is to use a shared lock
variable.
// Shared variable
boolean lock = false; // false means free, true means busy
// Process Pi
do {
while (lock == true) {
// busy wait
}
lock = true; // Acquire lock
// --- CRITICAL SECTION ---
lock = false; // Release lock
// --- REMAINDER SECTION ---
} while (true);
lock
is false
.lock
to true
, it's preempted.lock
is false
, sets lock
to true
, and enters CS.lock
to true
(it had already passed the while
condition), and enters CS.while (lock == true)
) and the acquisition (lock = true;
) are not atomic.
while
loop.This simple lock variable approach highlights the core problem: the need for atomicity. Checking the lock and setting it must be an indivisible operation.
This approach uses a shared turn
variable to ensure that two processes take turns entering their critical sections.
// Shared variable
int turn = 0; // Initially, process 0 can enter
// Process Pi (where i is 0 or 1)
do {
while (turn != i) {
// busy wait - wait for its turn
}
// --- CRITICAL SECTION ---
turn = 1 - i; // Pass the turn to the other process
// --- REMAINDER SECTION ---
} while (true);
turn == i
at any time.Peterson's solution is a classic software-based solution that combines the ideas of a turn variable and flag variables to satisfy all three critical section requirements for two processes.
// Shared variables:
boolean flag[2]; // Indicate if a process wants to enter the critical section. Initially false.
int turn; // Indicates whose turn it is.
// Initialization:
flag[0] = false;
flag[1] = false;
// turn can be initialized to 0 or 1. Let's say 0.
turn = 0;
void process_i_peterson(int i) { // i is the process ID (0 or 1)
int j = 1 - i; // The other process ID
while (true) {
flag[i] = true; // Indicate desire to enter critical section
turn = j; // Give priority to the other process (j) if contention
// Wait if Pj also wants to enter AND it's Pj's turn
while (flag[j] && turn == j) {
// Busy wait
}
// --- CRITICAL SECTION ---
// System.out.println("Process " + i + " is in Critical Section (Peterson)");
// --- END CRITICAL SECTION ---
flag[i] = false; // Indicate exit from critical section
// --- REMAINDER SECTION ---
}
}
Pi
enters its CS only if either flag[j]
is false
(Pj doesn't want to enter) or turn == i
(it's Pi's turn).
flag[j]
is false
), Pi can enter. If both are interested, one will eventually enter.
while (flag[j] && turn == j)
loop is a busy-wait loop.The key to Peterson's solution is that a process indicates its interest (flag[i] = true
) before setting the turn
variable. By setting turn = j
, process i
politely gives way to process j
if j
is also interested. If j
is not interested (flag[j] == false
), i
proceeds. If j
is interested, i
waits only if it's j
's turn.
Modern computer architectures provide special atomic instructions that can simplify the implementation of critical sections.
On a single-processor system, disabling interrupts before CS and re-enabling after can ensure ME.
The TestAndSet()
instruction atomically reads a boolean lock, sets it to true
, and returns the original value.
// Shared variable:
boolean lock_tsl = false;
// Atomic Test and Set Lock function (hardware-dependent)
boolean TestAndSet(boolean *target) {
boolean oldValue = *target;
*target = true;
return oldValue;
}
void process_using_tsl() {
while (true) {
while (TestAndSet(&lock_tsl)) {
// Busy wait
}
// --- CRITICAL SECTION ---
lock_tsl = false;
// --- REMAINDER SECTION ---
}
}
Bus Locking for Atomicity: Hardware often uses bus locking to ensure TestAndSet()
is atomic across multiple processors.
The AtomicSwap()
instruction atomically swaps the contents of two memory locations.
// Shared variable:
int lock_swap_var = 0; // 0 means unlocked, 1 means locked
// Atomic Swap function (hardware, simplified conceptual implementation)
void atomic_swap_int(int *val1, int *val2) {
// This operation must be atomic by hardware.
int temp = *val1; *val1 = *val2; *val2 = temp;
}
void process_using_swap() {
int local_key;
while (true) {
local_key = 1;
do {
atomic_swap_int(&local_key, &lock_swap_var);
} while (local_key != 0);
// --- CRITICAL SECTION ---
lock_swap_var = 0;
// --- REMAINDER SECTION ---
}
}
These primitives allow a waiting process to sleep, yielding the CPU.
A Semaphore is an integer variable accessed via atomic wait()
(P) and signal()
(V) operations.
typedef struct {
int value;
// Queue waiting_queue; // Pseudo-code for waiting queue
} Semaphore;
void wait_sem(Semaphore *S) { // Renamed to avoid conflict with HTML wait
S->value--;
if (S->value < 0) {
// add current process to S->waiting_queue;
// block(); // Suspend the process
}
}
void signal_sem(Semaphore *S) { // Renamed
S->value++;
if (S->value <= 0) {
// remove a process P from S->waiting_queue;
// wakeup(P); // Move P to the ready queue
}
}
S > 1
, ME for a single CS is violated.Semaphore mutex_sem_example = { .value = 1 }; // Initialize to 1
do {
wait_sem(&mutex_sem_example);
// --- CRITICAL SECTION ---
signal_sem(&mutex_sem_example);
// --- REMAINDER SECTION ---
} while (true);
A Mutex (Mutual Exclusion) Lock is specifically for enforcing ME.
lock()
(acquire), unlock()
(release).Feature | Mutex | Semaphore |
---|---|---|
Purpose | Mutual Exclusion | ME, Resource Counting, Signaling |
Ownership | Often yes | Generally no |
Producers add items to a shared buffer, consumers remove them. Buffer has finite size.
// Assume N is buffer_size
// Semaphore mutex_pc = { .value = 1 };
// Semaphore emptySlots_pc = { .value = N };
// Semaphore fullSlots_pc = { .value = 0 };
// Producer Process:
while (true) {
// item = produce_item();
wait_sem(&emptySlots_pc);
wait_sem(&mutex_pc);
// add_item_to_buffer(item);
signal_sem(&mutex_pc);
signal_sem(&fullSlots_pc);
}
// Consumer Process:
while (true) {
wait_sem(&fullSlots_pc);
wait_sem(&mutex_pc);
// item = remove_item_from_buffer();
signal_sem(&mutex_pc);
signal_sem(&emptySlots_pc);
// consume_item(item);
}
Order of wait_sem()
operations is CRUCIAL: In Producer, wait_sem(emptySlots_pc)
then wait_sem(mutex_pc)
. Reverse order risks deadlock if buffer is full. Similarly for Consumer.
Shared resource: Readers can access concurrently; Writers need exclusive access.
// Semaphore db_access_rw = { .value = 1 };
// Semaphore mutex_reader_count_rw = { .value = 1 };
int reader_count_rw = 0;
// Reader Process:
while (true) {
wait_sem(&mutex_reader_count_rw);
reader_count_rw++;
if (reader_count_rw == 1) { wait_sem(&db_access_rw); }
signal_sem(&mutex_reader_count_rw);
//
wait_sem(&mutex_reader_count_rw);
reader_count_rw--;
if (reader_count_rw == 0) { signal_sem(&db_access_rw); }
signal_sem(&mutex_reader_count_rw);
}
// Writer Process:
while (true) {
wait_sem(&db_access_rw);
//
signal_sem(&db_access_rw);
}
The shared integer reader_count_rw
is protected by its own mutex because incrementing/decrementing and checking its value must be atomic with respect to other readers.
Five philosophers, five chopsticks. Each needs two to eat. Illustrates deadlock and starvation.
int N_PHIL = 5;
// Semaphore chopsticks_dp[N_PHIL]; // Initialized to 1 each
void philosopher_dp(int i) {
while (true) {
// think()
wait_sem(&chopsticks_dp[i]);
wait_sem(&chopsticks_dp[(i + 1) % N_PHIL]);
// eat()
signal_sem(&chopsticks_dp[i]);
signal_sem(&chopsticks_dp[(i + 1) % N_PHIL]);
}
}
A Monitor is a high-level ADT encapsulating shared data and procedures operating on it. Only one process can be active within a monitor at a time (implicit mutual exclusion).
Used within monitors for processes to wait for specific conditions. Operations:
wait_cond(c)
: Suspends process, releases monitor lock. (Renamed)signal_cond(c)
: Resumes one waiting process (if any). (Renamed)monitor BoundedBufferMonitor {
// Buffer buffer_mon; int count_mon = 0; int BUFFER_SIZE_MON;
// Condition notFull_mon, notEmpty_mon;
procedure produce_mon(Item item) {
if (count_mon == BUFFER_SIZE_MON) { notFull_mon.wait_cond(); }
// add_item_to_buffer(item); count_mon++;
notEmpty_mon.signal_cond();
}
procedure Item consume_mon() {
if (count_mon == 0) { notEmpty_mon.wait_cond(); }
// Item item = remove_item_from_buffer(); count_mon--;
notFull_mon.signal_cond();
// return item;
}
}
Two or more processes indefinitely waiting for resources held by each other.
A process is perpetually denied resources, making no progress. Prevented by fair scheduling/allocation (e.g., bounded waiting).
Processes actively change state in response to others but make no useful progress. They are "busy" but stuck.
Two people try to step aside for each other in a narrow hallway, repeatedly mirroring moves and never passing. Unlike deadlock, processes are active, not blocked.
A high-priority task (H) is blocked by a lower-priority task (L) because L holds a resource H needs, and L itself is preempted by a medium-priority task (M).
System resets due to this. A low-priority meteorological task held a mutex, blocked by a high-priority bus task. A medium-priority communication task preempted the meteorological task, preventing mutex release, leading to a watchdog timeout.
Code behaves correctly when accessed by multiple threads concurrently. Achieved using synchronization mechanisms to protect shared data.
IPC mechanisms (pipes, shared memory, etc.) often require synchronization (e.g., semaphores for shared memory access) to ensure correct data exchange and coordination.
Ensuring shared data remains accurate, consistent, and valid despite concurrent access. Synchronization prevents race conditions, inconsistent updates, and lost updates, thereby maintaining data integrity.
The choice of synchronization mechanism depends on application needs, expected contention, lock duration, and hardware architecture.
This concludes Chapter 5 on Process Synchronization. A solid grasp of these principles is essential for developing reliable and efficient concurrent software.