Working with threads requires more than just starting and stopping them. You also need to make threads work together, and effective interaction requires control over timing. Timing control takes two forms: priority and synchronization. Priority controls how often a thread gets processor time. Synchronization regulates threads when they compete for shared resources and imposes a sequence when several threads must accomplish tasks in a certain order.
When the system scheduler preempts one thread and looks for another to run next, it gives preference to threads of high priority. Some activities, such as responding to an unexpected power loss, always execute at a very high priority. System interrupt handlers have a higher priority than user processes. Every process has a priority rating, and threads derive their base scheduling priority from the process that owns them.
As shown earlier in Figure 14.1, a thread object’s attributes include a base priority and a dynamic priority. When you call commands to change a thread’s priority, you change the base priority. You cannot push a thread’s priority more than two steps above or below the priority of its process. In other words, threads can’t grow up to be very much more important than their parent.
Although a process cannot promote its threads very far, the system can. The system grants a sort of field promotion—dynamic priority—to threads that undertake important missions. When the user gives input to a window, for example, the system always elevates all the threads in the process that owns the window. When a thread waiting for data from a disk drive finally receives it, the system promotes that thread, too. These temporary boosts, added to the thread’s current base priority, form the dynamic priority. The scheduler chooses threads to execute based on their dynamic priority. Process, base, and dynamic priorities are distinguished in Figure 14.2.
Dynamic priority boosts begin to degrade immediately. A thread’s dynamic priority slips back one level each time the thread receives another time slice and finally stabilizes at the thread’s base priority.
Figure 14.2: How the range of a thread’s priority derives from the priority of the process
To select the next thread, the scheduler begins at the highest priority queue, executes the threads there, and then works its way down the rest of the list. But the dispatcher ready queue may not contain all the threads in the system. Some may be suspended or blocked. At any moment, a thread may be in one of six states:
When the scheduler selects a ready thread from the queue, it loads a context for the thread. The context includes a set of values for the machine registers, the kernel stack, a thread environment block, and a user stack in the address space of the thread’s process. (If part of the context has been paged to disk, the thread enters the transition state while the system gathers the pieces.)
Changing threads means saving all the pieces of one context and loading all the pieces of the next one into the processor. The newly loaded thread runs for one time slice, which is likely to be on the order of 20 milliseconds. The system maintains a counter measuring the current time slice. On each clock tick, the system decrements the counter; when it reaches zero, the scheduler performs a context switch and sets a new thread running.
For those familiar with how multiple executables function under, for example, Windows 3.1, executing threads is very much like executing separate applications. The real difference is simply that single applications are now able to separate tasks for parallel execution instead of executing in serial fashion.
To run at all, threads must be scheduled; to run well, they often need to be synchronized. Suppose one thread creates a brush and then creates several threads that share the brush and draw with it. The first thread must not destroy the brush until the other threads finish drawing. Or suppose one thread accepts input from the user and writes it to a file, while another thread reads from the file and processes the text. The reading thread must not read while the writing thread is writing. Both situations require a means of coordinating the sequence of actions in several threads.
One solution would be to create a global Boolean variable that one thread uses to signal another. The writing thread might set bDone to TRUE, and the reading thread might loop until it sees the flag change. That would work, but the looping thread wastes a lot of processor time. Instead, Win32 supports a set of synchronization objects:
All are system objects created by the Object Manager. Although each synchronization object coordinates different interactions, they all work in a similar way. A thread that wants to perform some coordinated action waits for a response from one of these objects and proceeds only after receiving it. The scheduler removes waiting objects from the dispatch queue so they do not consume processor time. When the signal arrives, the scheduler allows the thread to resume.
How and when the signal arrives depends on the object. For example, the one essential characteristic of a mutex is that only one thread can own it. A mutex doesn’t do anything apart from letting itself be owned by one thread at a time (mutex stands for mutual exclusion). If several threads need to work with a single file, you might create a mutex to protect the file. Whenever any thread begins a file operation, it first asks for the mutex. If no one else has the mutex, the thread proceeds. If, on the other hand, another thread has just grabbed the mutex for itself, the request fails and the thread blocks, becoming suspended while it waits for ownership. When one thread finishes writing, it releases the mutex, and the waiting thread revives, receives the mutex, and performs its own file operations.
The mutex does not actively protect anything. It only works because the threads that use it agree not to write to the file without owning the mutex first. Nothing actually prevents all the threads from trying to write at once. The mutex is just a signal, much like the Boolean bDone in our looping example. You might create a mutex to protect global variables, a hardware port, a handle to a pipe, or a window’s client area. Whenever several threads share any system resource, you should consider whether to synchronize their use of it.
Mutexes, semaphores, and events can coordinate threads in different processes, but critical sections are visible only to threads in a single process. When one process creates a child process, the child often inherits handles to existing synchronization objects. Critical section objects cannot be inherited.
Fundamentally a synchronization object, like other system objects, is simply a data structure. Synchronization objects have two states: signaled and not signaled. Threads interact with synchronization objects by changing the signal or waiting for the signal. A waiting thread is blocked and does not execute. When the signal occurs, the waiting thread receives the object, turns the signal off, performs some synchronized task, and turns the signal back on when it relinquishes the object.
Threads can wait for other objects besides mutexes, semaphores, events, and critical objects. Sometimes it makes sense to wait for a process, a thread, a timer, or a file. These objects serve other purposes as well, but like the synchronization objects, they also possess a signal state. Processes and threads signal when they terminate. Timer objects signal when a certain interval passes. Files signal when a read or write operation finishes. Threads can wait for any of these signals.
Bad synchronization causes bugs. For example, a deadlock bug occurs when two threads wait for each other. Neither will end unless the other ends first. A race condition occurs when a program fails to synchronize its threads. Suppose one thread writes to a file and another thread reads the new contents. Whether the program works depends on which thread wins the race to its I/O operation. If the writing thread wins, the program works. If the reading thread tries to read first, the program fails.