Threads & Multithreaded Servers
A server that handles one request at a time leaves its CPU idle while it waits for the disk and makes every other client queue. Threads fix that, but they share memory, so they must be coordinated. This Part covers what the operating system gives you, how Java threads work, how to keep shared data safe, and the four standard ways to organise a multithreaded server.
- You willsay what a process shares with its threads and what each thread keeps private, and why that makes threads cheap but unprotected.
- You willcreate, start and control Java threads, and draw the thread life cycle from memory.
- You willspot race conditions, check-then-act bugs and deadlocks, and fix them with
synchronized,wait/notify, read–write locks and lock ordering. - You willcompare worker pool, thread-per-request, thread-per-connection and thread-per-object servers, and compute a server's throughput.
Middleware and the Operating System
A distributed application runs on many computers at once, and each of those computers may run a different operating system on different hardware. Two layers of software support it. The operating system (OS) on each node looks after that one machine, and middleware spans all the nodes and hides their differences. Knowing which layer does what is the starting point for this Part, because threads are one of the things the OS provides and middleware relies on.
This Part draws on CDK chapter 7 (§7.1, §7.2 and §7.4), lecture L3 and Buyya's chapter on Java threads. The OS layer itself, protection and kernel architectures are covered in Part V. The material matters twice over, because Java threads, locking and threaded socket servers are also the core of Assignment 1, the multi-threaded Subject Enrolment Server.
What each layer does exam
Middleware is a layer of software between the applications and the operating systems of the nodes of a distributed system. It provides high-level, uniform services across heterogeneous platforms, where a platform means the combination of an OS and the hardware it runs on (for example, Windows on an x86 PC, or macOS on an ARM laptop).
Middleware's services fall into four groups: communication (remote procedure call and remote method invocation, usually shortened to RPC and RMI, plus events and group communication), management, application-specific services, and a runtime environment in which the application runs. RPC and RMI let a program call a procedure or method that lives in another process as if it were local; Part VI covers them in detail.
The OS, by contrast, provides the low- to medium-level core features of one machine:
- process and thread management (creating them, scheduling them on the CPU);
- management of the local hardware: the CPU (central processing unit, the chip that executes instructions), memory and disk;
- security: users, groups and ACLs (access control lists, which say who may use which resource);
- basic networking.
With these features the OS lets a server encapsulate and protect its resources, so that other programs can reach them only through the server's interface, and it provides the mechanisms to access them, including communication and concurrent processing (doing several things at the same time). Middleware then builds remote invocation on top of these local facilities. The diagram below shows the arrangement.
Network OS vs distributed OS exam
An OS can treat a networked collection of machines in one of two ways. A network OS (NOS) leaves each node in charge of itself: every machine runs its own OS, and the user decides where programs run. A distributed OS (DOS) tries to make the whole network look like a single machine, called a single system image, and places processes on nodes by itself. A process here simply means a running program; section 4.2 defines it properly.
ssh, a remote login tool).| Network OS (NOS) | Distributed OS (DOS) | |
|---|---|---|
| System image | Multiple, one per node | Single system image |
| Autonomy | Each node manages its own processes | One OS controls all nodes |
| Process placement | User chooses (e.g. ssh to a host and run there) | OS places processes transparently (e.g. on the least-loaded node) |
| Transparency | Only for some resources (e.g. files via NFS, the Network File System of Part VIII) | Full location transparency: you never need to know where anything is |
| Examples | UNIX/Linux, Windows, Mac OS | Research prototypes only (e.g. Amoeba). None in general use |
Notice the last row: no distributed OS is in general use. CDK (§7.1) gives two reasons why the combination of middleware and network OS won instead.
- Investment in applications. Users will not adopt an OS that cannot run the applications they already have. One workaround is to emulate existing OSs on new kernels, but such emulation performed poorly, and keeping emulations up to date is a huge job.
- Autonomy. Users want control over their own machines and predictable performance on them. In CDK's example, Jones does not want Smith's jobs, placed on her machine by a distributed OS, slowing down her editor.
So the two layers divide the work. The network OS provides autonomy and runs standalone applications; middleware adds network-transparent access to distributed services on top of it. Neither a DOS nor a NOS alone is enough: middleware is the "glue" that sits on top of the NOS.
Check yourselfWhich statement about network and distributed operating systems is correct?
- Linux is a distributed OS because it supports NFS.
- A distributed OS gives one system image per node.
- In a network OS the user decides on which node a process runs.
- Distributed OSs replaced network OSs in the 2000s.
C. In a network OS each node is autonomous, so the user chooses the node (e.g. by ssh-ing to it). A is wrong: Linux is a network OS; NFS gives transparency for files only. B is backwards: a DOS gives a single system image. D is false: no DOS is in general use, because of application investment and users' wish for autonomy.
Core OS components exam
Inside one node, CDK's Figure 7.2 divides the core of the OS into five components, each with its own responsibility. Two of them, the process manager and the thread manager, are what the rest of this Part relies on.
Check yourselfWhich core OS component dispatches a system-call trap, and which one would a Java program indirectly use when it calls new Thread(r).start()?
The supervisor dispatches interrupts, system-call traps and exceptions. Starting a thread asks the thread manager, which handles thread creation, synchronisation and scheduling (on a kernel-threads JVM; see section 4.10).
- The OS manages one node's processes, threads, hardware, security and basic networking; middleware spans nodes and gives uniform high-level services across heterogeneous platforms.
- A network OS keeps one image per node and node autonomy; a distributed OS offers one image but none is in general use, because of application investment and autonomy. NOS + middleware won.
- CDK's five core OS components: process manager, thread manager, communication manager, memory manager, supervisor.
Processes and Threads
Before turning to Java, it is worth getting the vocabulary straight. A server wants to do many things at once, and the OS offers two units of concurrency to do it with: processes and threads. They differ in what they share, and that one difference decides how expensive they are and how much they protect you from mistakes.
Multitasking vs multithreading
Both terms describe doing several things at once, but at different levels. Multitasking happens at the OS level and is process-based: several applications run concurrently and the CPU switches between them. It comes in two forms. In cooperative multitasking each application voluntarily gives up the CPU (it yields); in preemptive multitasking a hardware timer ends each timeslice (the short period a task is allowed to run) and the OS performs the switch, whether the task likes it or not.
Multithreading happens at the application level and is thread-based: several activities run concurrently within one process. A word processor, for example, can edit, render its window and print in the background at the same time.
Processes, execution environments and threads exam
A process is a program in execution. In CDK's terms, a process is an execution environment plus one or more threads.
The execution environment is the unit of resource management and the protection domain for its threads (the boundary that stops other processes touching its resources). It is expensive to create. It contains an address space (the range of memory addresses the process can use); synchronisation and communication resources such as semaphores and sockets; and higher-level resources such as open files and windows.
A thread is the OS abstraction of an activity: a dispatchable unit of work, sometimes called a "lightweight process". It owns only its stack, registers/program counter, priority and execution state, and shares everything else in its process.
A semaphore is an OS counter that threads use to wait for and signal each other, and a socket is an endpoint for network communication (Part III). The program counter is the register that holds the address of the next instruction, and the stack is the memory where a thread keeps the local variables and return addresses of the methods it is currently executing. The figure shows what is shared and what is private.
A context switch is the act of saving one thread's processor registers and loading another's, so that the CPU continues with the other thread. Sometimes it also involves a domain transition, a change of address space and/or of processor mode (between the restricted user mode and the privileged kernel mode in which the OS runs). As the next pictures show, that extra step is what makes some switches expensive.
Process vs thread exam
Setting the two side by side shows the trade-off: threads are much cheaper to create and to switch between, but they give up protection from one another.
| Aspect | Process | Thread |
|---|---|---|
| Address space | Own, protected | Shared with sibling threads |
| Creation cost | High: new address-space tables and resources (CDK: about 11 ms for a UNIX process) | Low: allocate a stack, set registers and state (about 1 ms) |
| Switch cost | High: domain transition, TLB/cache flush (about 1.8 ms) | Low: about 0.4 ms via the kernel, 0.04 ms at user level |
| Communication | IPC (interprocess communication) via the kernel: pipes, sockets | Shared variables |
| Protection | Protected from other processes | Not protected from sibling threads |
CDK sums up the comparison in four points, which are worth quoting as they stand in an exam answer:
- Creating a thread is cheaper than creating a process.
- More importantly, switching between threads of the same process is cheaper than switching between processes.
- Threads share data and resources conveniently and efficiently.
- By the same token, threads are not protected from one another.
Why a process switch costs more: the aliasing problem
Programs use virtual addresses, which the MMU translates to physical addresses in RAM. To make that fast, the CPU keeps a TLB (translation lookaside buffer), a small cache of recent translations, and often a cache of memory contents indexed by virtual address. The trouble is aliasing: the same virtual address means different data in different address spaces. So when the CPU switches to another address space, any untagged TLB entries and virtually addressed cache lines are now wrong and must be flushed. The new process then suffers cache misses and page faults while they refill.
Address spaces and creating a process (brief)
CDK §7.4.1–7.4.2 covers these only briefly. An address space is a set of non-overlapping regions, such as the program text (its code), the heap, one stack per thread, and mapped files. Each region has an extent (start and size), permissions (read, write, execute) and a direction in which it can grow. Shared regions are used for libraries, for the kernel (which is mapped into every process) and for sharing data between processes.
Creating a process in a distributed system has two parts. The first is choosing a host: a transfer policy decides whether to create the process locally or remotely, and a location policy decides which node it goes to. The second is creating the execution environment. The UNIX fork call, for example, creates a child process whose environment is a copy of its parent's. Copying everything up front would be slow and often wasted, so copy-on-write is used: the inherited regions share the same page frames (fixed-size blocks of physical memory), which are write-protected, and a page is physically copied only when one of the processes writes to it. Step through it below.
- The parent process maps its pages 0, 1 and 2 to physical frames A, B and C.
- The parent calls
fork(). The child gets its own page table, but its entries point at the same frames. Nothing is copied yet; instead the shared frames are marked write-protected (the red locks). - The child writes to page 2. Because frame C is write-protected, the hardware raises a page fault and the kernel takes over.
- The kernel copies frame C into a free frame D and points the child's page 2 at D. Now each process has its own writable copy of that page, while pages 0 and 1 are still shared. Only the pages someone actually writes are ever copied.
What threads are used for
Threads are used in six common situations. The first is easiest to picture: several independent calculations run side by side and the main thread waits for all of them with join(), which blocks until a thread finishes.
join() makes main wait until the thread it is called on has terminated, so the sum uses three finished results.sin, cos and tan above.Check yourselfTwo threads of the same process both run a method containing int n = 0; n++; and the statement count++; where count is an instance field of a shared object. Which variable can they interfere on, and why?
Only count. n is a local variable, so each thread has its own copy on its own private stack. count is an instance field of an object on the heap, which is part of the shared execution environment, so both threads update the same memory location.
Check yourselfWhich is not part of a process's execution environment?
- Its open sockets
- Its address space
- A thread's program counter
- Its semaphores
C. The program counter (with the other registers, the stack, the priority and the execution state) belongs to an individual thread. The address space, sockets, semaphores, open files and windows form the shared execution environment.
Check yourselfWhy is switching between processes more expensive than switching between threads of one process?
A process switch is a domain transition: the address space changes. Because of the aliasing problem (one virtual address means different data in different address spaces), untagged TLBs and virtually addressed caches must be flushed, and the new process then suffers cache misses and page faults. A thread switch within one process only saves and loads registers; the address space, and so the TLB, stay valid.
- A process = an execution environment (address space, sockets, semaphores, files, windows; the unit of resource management and protection) + one or more threads.
- A thread owns only its stack, registers/PC, priority and state; everything else is shared with its siblings.
- So threads are cheaper to create and much cheaper to switch (no domain transition, no TLB flush), share data easily, and are not protected from each other.
- Copy-on-write lets
forkshare frames until someone writes.
Creating Java Threads
With the concepts in place, we turn to how Java exposes threads. Every Java program already has one thread, the one that runs main. To do two things at once you create more, and Java gives you two ways to say what a new thread should run. In both cases the code the thread executes goes in a method called run(), and in both cases nothing happens until you call start().
Two ways to create a thread exam
In the first way you extend Thread and override run(), so the object itself is a thread. In the second you implement Runnable, an interface with the single method run(), so the object is only a task, and a separate Thread object runs it. The example shows both.
// Way 1: extend Thread and override run(). The object IS a thread.
class A extends Thread {
public void run() {
for (int i = 1; i <= 5; i++) System.out.println("\t From ThreadA: i= " + i);
System.out.println("Exit from A");
}
}
// Way 2: implement Runnable. The object is a task that a Thread runs.
class Task implements Runnable {
public void run() { System.out.println("in " + Thread.currentThread().getName()); }
}
public class ThreadEx {
public static void main(String[] args) throws InterruptedException {
A a = new A(); // NEW: no thread running yet
Thread t = new Thread(new Task()); // wrap the Runnable
a.start(); // JVM creates a thread that calls run()
t.start();
a.join(); t.join(); // main waits for both to terminate
}
}
Reading it line by line:
class A extends Threadwith its ownrun(): the body is what the new thread will execute, here a loop printing five lines and then "Exit from A".class Task implements Runnable: itsrun()prints the name of whichever thread is executing it, found with the static methodThread.currentThread().A a = new A();creates aThreadobject in the New state. It is just an object; no thread is running yet.new Thread(new Task())wraps the task in aThreadobject, which will call the task'srun()when started.a.start()asks the JVM (the Java Virtual Machine, the runtime that executes Java programs) to create a real thread, which will later calla.run().start()returns immediately, somaincarries straight on tot.start().a.join(); t.join();makemainwait until each thread has terminated.join()can throwInterruptedException(if another thread interrupts the waiting one), which is whymaindeclaresthrows InterruptedException.
extends Thread. The hollow triangle means "is a kind of". A inherits start() and overrides run().implements Runnable. The dashed triangle means "implements". The Thread object holds the task as its target and calls the task's run().The two approaches differ in what the object is and in what they cost you in inheritance. Java allows a class to extend only one superclass, so extending Thread uses up that one chance. Runnable is usually the better choice.
extends Thread | implements Runnable | |
|---|---|---|
| Object is | A thread | A unit of work, run with new Thread(r) (or a lambda) |
| Inheritance | Uses up Java's single inheritance | Can still extend another class (e.g. MultiThreadMathServer extends MathServer implements Runnable) |
| Control | Full control over the thread life cycle | Defines only the work |
| Use when | You need to customise the thread itself | Usually preferred: flexible, shareable, works with thread pools |
A lambda is Java's short syntax for an object with one method, so new Thread(() -> work()) is the same as passing a Runnable whose run() calls work(). A thread pool (section 4.8) is a fixed set of threads that run many submitted Runnable tasks, which is one reason the task-only style is so useful.
start() vs run() exam
This is the classic trap. Calling start() creates a new thread, which the scheduler later dispatches to execute run(); start() itself returns immediately. Calling run() directly is just an ordinary method call in the current thread, so nothing runs concurrently.
run(). Both bodies execute sequentially in main.start(). Each call creates a new thread and returns at once.A thread can also be started only once. Calling start() a second time throws IllegalThreadStateException, and a thread that has finished (is dead) cannot be restarted; you must create a new Thread object.
Check yourselfmain calls t1.run(); t2.run();. How many threads run, and in what order do the bodies execute?
Only one thread, main. run() called directly is an ordinary method call, so t1's body runs to completion and then t2's body runs, both in main. To get concurrency you must call start().
Check yourselfGive two reasons to prefer implements Runnable over extends Thread.
(1) The class can still extend another class, because Java has single inheritance (e.g. MultiThreadMathServer extends MathServer implements Runnable). (2) The task is kept separate from the thread that runs it, so the same task can be shared or handed to a thread pool. Extending Thread is only needed when you want to customise the thread itself.
- Two ways:
extends Thread(object is a thread) orimplements Runnablepassed tonew Thread(r)(object is a task). PreferRunnable. start()creates a new thread that later callsrun(); callingrun()yourself is a plain method call in the current thread.- A thread can be started once; a dead thread cannot be restarted.
Thread Life Cycle, Control and Scheduling
Once a thread exists, you need to know what it is doing at any moment: is it running, waiting for its turn, asleep, or finished? The answer is its state, and the methods you call on a thread are mostly ways of moving it between states. This section draws the life cycle, lists the control methods (and the dangerous ones you must not use), and explains why you can never predict the order in which threads run.
The thread life cycle exam
The slides model a thread's life as seven states, with the method calls and events that move it from one to another. Read the diagram from the black dot (the start) to the ringed dot (the end).
Thread.State names; note that Ready and Running are both RUNNABLE in Java.In words: a thread starts New (the object exists, no thread yet). start() makes it Ready (able to run, waiting for a CPU). The scheduler dispatches it, making it Running; yield() or the end of its timeslice sends it back to Ready. From Running it can go Sleeping (sleep(ms)), Waiting (wait(), until notified) or Blocked (waiting for I/O, or suspended). When run() finishes (or, in old code, on stop()) it is Dead.
Java's own Thread.State enum uses different names, and the exam may use either set:
| Slides' state | Java Thread.State | Notes |
|---|---|---|
| New | NEW | Created, not yet started |
| Ready + Running | RUNNABLE | Java does not distinguish "waiting for a CPU" from "on a CPU" |
| Blocked | BLOCKED | In Java, means waiting for a monitor lock (4.5); the slides also include blocking on I/O |
| Waiting | WAITING | Entered through wait() or join() |
| Sleeping | TIMED_WAITING | Entered through sleep(ms) |
| Dead | TERMINATED | run() has finished |
Sleeping, waiting and blocked threads are all still alive (isAlive() returns true). When they wake, they return to Ready, not straight to Running.
CDK calls the initial state "SUSPENDED" and lists a destroy() method. That method was never implemented and has been removed from Java. Use the slides' model in the exam. The tutors also accept a simpler five-state model, shown with the Tutorial 3 answers in section 4.11.
Thread methods exam
These are the main methods for controlling a thread. "Static" means you call it on the class, Thread.sleep(100), and it acts on whichever thread is executing the call.
| Method | What it does | Watch out |
|---|---|---|
sleep(ms) (static) | Pauses the current thread for at least ms milliseconds | Keeps its locks while it sleeps; throws InterruptedException |
yield() (static) | A hint that the running thread should move back to Ready | Only a hint; the same thread may be chosen to run again |
join(), join(ms) | The caller waits until the target thread terminates, or until the timeout expires | Throws InterruptedException |
isAlive() | True if the thread has been started and is not yet dead | |
interrupt() | Wakes the target from sleep, wait or join with an InterruptedException | If the target is not in one of those calls, it just sets a flag the thread can check |
setPriority, getPriority | Set and read the priority, from 1 to 10 | Only a hint to the scheduler |
setDaemon(true) | Makes it a background (daemon) thread; the JVM exits when only daemons remain | Must be called before start(). A daemon is not the same as a low-priority thread |
stop(), suspend(), resume() | Kill, freeze and unfreeze a thread | Deprecated, and unsupported in recent JDKs |
Why stop, suspend and resume are deprecated exam
The reason comes down to locks, which section 4.5 introduces properly; for now, a lock is a token that only one thread can hold at a time, used to stop other threads touching an object while it is being changed.
stop() releases all locks mid-update. Objects the thread was halfway through changing are left inconsistent and visible to other threads.suspend() freezes a thread that still holds its locks. If the thread that would call resume() needs one of those locks, nobody can move.stop() kills a thread at an arbitrary point and releases all its locks, so any objects it was halfway through updating are left inconsistent and visible to other threads. suspend() has the opposite problem: it freezes a thread while it still holds its locks, so if the thread that would call resume() needs one of those locks, the result is deadlock (threads waiting for each other for ever; section 4.7). resume() exists only to pair with suspend(), so it goes too.
Leave the thread in charge of itself. To stop a thread, set a flag (or call interrupt()) that the thread checks, so it returns from run() by itself at a safe point. To pause a thread, use wait/notify (section 4.6).
class Worker extends Thread {
private volatile boolean running = true; // volatile: other threads see changes at once
public void shutdown() { running = false; interrupt(); }
public void run() {
while (running) { /* do one unit of work, then re-check the flag */ }
}
}
The sketch above is one way to code the flag; the keyword volatile makes every thread read the field's latest value rather than a cached copy. CDK also describes thread groups: every thread belongs to a group, and groups serve two purposes. One is security, since a thread cannot manage threads in another group. The other is priority control, since a group's maximum priority caps the priorities of its members; this is used for applets and servlets (small programs hosted inside a browser or a web server).
Start order is not run order exam
This example shows why you cannot predict the order in which threads run. main starts three threads, each of which prints five lines and then "Exit from X".
// class A is as in section 4.3; B and C are identical but print "ThreadB" / "ThreadC"
public class ThreadTest {
public static void main(String[] args) {
new A().start();
new B().start();
new C().start();
}
}
println, so "Exit from A" appeared last.The output varies because start() only makes a thread Ready. When the thread actually runs, and when it is preempted (taken off the CPU), is up to the JVM/OS scheduler. That timing depends on timeslices, the other load on the machine, JIT compilation and garbage collection activity (both done by the JVM in the background), and the number of cores, so it changes from run to run.
Start order is not execution or completion order, and correctness must never rely on it. If order matters, enforce it with join, synchronized or wait/notify.
Priorities and scheduling
Java lets you influence the scheduler through priorities. The constants are MIN_PRIORITY = 1, NORM_PRIORITY = 5 and MAX_PRIORITY = 10. NORM_PRIORITY is the default, and a new thread inherits its priority from the thread that created it. According to the slides, equal-priority threads are served first-come-first-served and higher priority runs first. Both the slides and Buyya describe the garbage collector (the JVM's background memory recycler) as a low-priority thread.
A threadA = new A(); B threadB = new B(); C threadC = new C();
threadC.setPriority(Thread.MAX_PRIORITY); // 10
threadB.setPriority(threadA.getPriority() + 1); // A is still NORM (5), so B gets 6
threadA.setPriority(Thread.MIN_PRIORITY); // 1
threadA.start(); threadB.start(); threadC.start(); // typical output: C first, A last
In the slides' priority example above, a typical output runs C first and A last. But priorities are only hints to the OS scheduler, so use them to favour work, never for correctness. A server, for example, can give a class of premium customers higher-priority threads.
Preemptive vs non-preemptive scheduling exam
CDK distinguishes two ways a thread scheduler can behave, and the difference decides whether you need synchronisation at all.
A thread can be suspended at any point to let another run. So shared data must be synchronised. In return, the threads can run on multiple processors. Java threads behave this way in practice.
A thread runs until it calls the threading system, e.g. a system call or yield(). Advantage: any code without such calls is automatically a critical section, so there are no races. Disadvantages: it cannot exploit a multiprocessor, long computations need explicit yield() calls, and it is unsuited to real-time work.
Check yourselfThreads A, B and C are started in that order. Must A run first?
No. start() only makes a thread Ready; when it is dispatched and when it is preempted are decided by the JVM/OS scheduler, so the order varies between runs and between machines with different numbers of cores. If order matters, enforce it with join, locks or wait/notify.
Check yourselfA sleeping thread's sleep time expires. Which state does it enter?
- Running
- Ready
- Waiting
- New
B, Ready. Every exit from Sleeping, Waiting or Blocked goes back to Ready; the thread runs again only when the scheduler dispatches it.
Check yourselfWhy was suspend() deprecated, and what should you use instead?
suspend() freezes a thread while it still holds its locks. If the thread that would call resume() needs one of those locks, neither can proceed: deadlock. To pause a thread safely, let it wait on a condition with wait/notify, which releases the lock while waiting. (stop() is deprecated for the opposite reason: it releases all locks mid-update, leaving objects inconsistent.)
Check yourselfTrue or false: a daemon thread is a low-priority thread.
False. A daemon is a background thread: the JVM exits when only daemon threads remain. Its priority is a separate setting. setDaemon(true) must be called before start().
- Life cycle: New → (start) Ready ⇄ Running → Sleeping / Waiting / Blocked → back to Ready; Running → Dead when
run()ends. Dead threads cannot restart. sleepkeeps locks;yieldis a hint;joinwaits for termination;interruptwakes sleep/wait/join.stop/suspend/resumeare deprecated because of locks.- Start order is not run order; priorities are hints. Preemptive scheduling (Java) needs synchronisation; non-preemptive avoids races but cannot use multiprocessors.
Race Conditions and synchronized
Threads share their process's data, and a preemptive scheduler can switch between them at any instruction. Put those two facts together and two threads can trip over each other while updating the same field. This section shows exactly how that goes wrong, step by step, and then how Java's synchronized keyword prevents it.
The problem: a race condition exam
Access to a shared resource must be coordinated. A printer cannot print two jobs at once, and the calls deposit(), withdraw() and enquire() on one bank account must not interleave. When accesses do interleave, the result can be a race condition: the outcome depends on the unpredictable interleaving of the threads that access the shared data.
The key insight is that balance += amt looks like one step but is really three: read the field into a register, add, and write the result back. A thread can be preempted between any two of them. In the stepper, the account starts with 100; thread 1 deposits 30 while thread 2 withdraws 50, so the right answer is 80.
- The shared field
balanceholds 100. Thread 1 is about to runbalance += 30and Thread 2balance -= 50. Each statement is really read, compute, write. - Thread 1 reads
balanceinto its own register:r = 100. Then the scheduler preempts it. - Thread 2 runs and also reads 100. Both threads now hold a private copy of the same old value.
- Thread 1 resumes and computes 100 + 30 = 130 in its register. Memory still says 100.
- Thread 2 computes 100 − 50 = 50, also from the stale 100.
- Thread 1 writes 130. For a moment the balance looks right for a deposit.
- Thread 2 writes 50, overwriting 130. Thread 2 never saw the deposit.
- The update is lost: the balance is 50 instead of 80. Run the program again and you may get 80, 130 or 50, depending on the interleaving. That unpredictability is what makes races so hard to find by testing.
The same thing happens on the server side. CDK's example is an I/O thread and several worker threads manipulating a shared queue of requests: without coordination, requests can be lost or handed to two workers.
A race condition is a bug where the result depends on the unpredictable interleaving of threads accessing shared data.
A critical section is code that accesses shared data and must be executed by at most one thread at a time. Guaranteeing that is called mutual exclusion.
To work out what is shared, use the picture from section 4.2: local variables live on each thread's private stack, whereas instance and static fields live in shared objects and are shared. In the example, r is private and balance is shared.
The course example is Internet banking, where deposit and withdraw threads share one Account. With synchronized methods the balances are consistent. Without it, Buyya's run printed "balance is 60.0" after every operation, which is plainly a wrong result.
The fix: synchronized, monitors and intrinsic locks exam
Java's basic tool for mutual exclusion is the keyword synchronized. Every Java object has an intrinsic lock (also called its monitor lock), and synchronized uses it to implement the monitor construct: at most one thread executes inside an object's synchronized code at a time. Any other thread that tries to enter is BLOCKED until the lock is free.
wait() (section 4.6); they must be notified, and then reacquire the lock, before they can continue.There are two forms. A synchronized method locks this, the object it is called on; a static synchronized method locks the Class object instead (one per class). A synchronized block, written synchronized (obj) { … }, locks a chosen object around a smaller critical section.
class Account {
private double balance;
public synchronized void deposit(double amt) { balance += amt; } // locks this
public void withdraw(double amt) {
// ... work that needs no lock can go here ...
synchronized (this) { balance -= amt; } // block: locks this, briefly
}
public static synchronized void audit() { /* locks Account.class */ }
}
Two properties make these locks easy to use. They are reentrant: the thread holding the lock can call other synchronized methods on the same object without blocking on itself. And the lock is released automatically when the method or block exits, even if it exits by throwing an exception.
The example below puts this together. Two threads increment one shared Counter 100,000 times each; synchronising increment() makes its read-modify-write atomic (indivisible), so no update can be lost.
class Counter { // the "monitor"
private int count = 0;
public synchronized void increment() { count++; } // read-modify-write is now atomic
public synchronized int get() { return count; }
}
public class RaceDemo {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable task = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread t1 = new Thread(task), t2 = new Thread(task); // both share ONE Counter
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.get()); // always 200000; without synchronized, usually less
}
}
count++is read-modify-write, exactly likebalance += amt, so without the lock two threads can lose increments.- The lambda
taskis aRunnable; both threads run the same task on the sameCounter c, which is what makescountshared. get()is synchronized too, so it reads a consistent value.join()makesmainwait for both threads before printing. Removesynchronizedand the total is usually less than 200,000.
Here is the same deposit/withdraw race again, this time with both methods synchronized.
- Same start:
balanceis 100 and the account's lock is free. Nowdepositandwithdraware bothsynchronized. - Thread 1 calls
deposit(30)and acquires the account's lock on entry. - Thread 1 reads 100. Suppose it is preempted right here, exactly as before.
- Thread 2 calls
withdraw(50), but the lock is held by Thread 1, so Thread 2 is BLOCKED at the door. It cannot read the stale 100. - Thread 1 resumes, computes 130 and writes 130, still holding the lock.
- Thread 1 returns from
deposit, which releases the lock automatically. Thread 2 is woken and acquires it. - Thread 2 now reads 130, the up-to-date value, and computes 80.
- Thread 2 writes 80 and returns, releasing the lock.
- The result is 80, the correct answer. Each method body ran as one indivisible critical section, so the read-compute-write steps of the two threads could no longer interleave.
Locks belong to objects
A synchronized instance method locks its own object. Two threads calling the same synchronized method on different objects do not block each other at all, because they take different locks.
The lock only stops other synchronized code on the same object. CDK points out that mixing synchronized and unsynchronized access to the same field is unsafe: an unsynchronized method simply walks in without asking for the lock.
increment() is synchronized but reset() is not, the class is not thread-safe.Check yourselfwithdraw() is synchronized. Thread A calls account1.withdraw() and thread B calls account2.withdraw() at the same moment. What happens?
- B blocks until A returns.
- Both run concurrently.
- A
IllegalMonitorStateException is thrown. - They deadlock.
IllegalMonitorStateException is thrown.B. A synchronized instance method locks this, and these are two different objects with two different locks, so neither waits for the other.
Check yourselfWhy is count++ not safe when two threads share count, and what exactly does making increment() synchronized change?
count++ is three steps (read, add, write). Two threads can both read the same old value and both write old+1, losing an increment. Making increment() synchronized means a thread must hold the object's intrinsic lock to run it; a second thread trying to enter is BLOCKED until the first returns and the lock is released. So the three steps of one call can never interleave with another call's: the method body becomes a critical section.
Check yourselfA synchronized method calls another synchronized method on the same object. Does the thread block on its own lock?
No. Java's intrinsic locks are reentrant: the thread that already holds the lock can enter other synchronized code on the same object.
- A race condition: the outcome depends on the interleaving.
x += yis read, compute, write, so updates can be lost. - Protect each critical section with mutual exclusion. Fields are shared; locals are private.
synchronizeduses the object's intrinsic (monitor) lock: one thread inside at a time; methods lockthis(static: theClass), blocks lock a chosen object. Reentrant, released automatically even on exceptions.- Every access to the shared field must use the same lock; one unsynchronized method breaks it.
wait, notify and Producer–Consumer
Mutual exclusion stops two threads changing data at the same time, but it is not enough when a thread must wait for a condition, such as "the queue is non-empty". A consumer that finds an empty queue cannot just loop and check again while holding the lock, because then no producer could ever get in to add an item. It needs a way to sleep, give up the lock, and be woken when things change. In Java, any object can play that role, acting as a condition variable.
How wait and notify work exam
- A thread calls
wait()on an object while holding that object's lock (inside synchronized code). wait()releases the lock and blocks the thread in the object's wait set until it is notified, interrupted or (withwait(ms)) timed out.- Another thread, holding the same lock, calls
notify()to wake one waiting thread, ornotifyAll()to wake all of them. Woken threads then compete for the lock. - The woken thread reacquires the lock before
wait()returns, and carries on inside the synchronized code.
Calling wait, notify or notifyAll without holding the object's lock throws IllegalMonitorStateException. Look back at the monitor figure in section 4.5: wait() moves the owner into the wait set, and notification moves a waiter back to the entry set.
while loop
Always write while (!condition) wait();, never if, so that the condition is re-tested after waking. There are two reasons. Another thread may change the state before the woken thread reacquires the lock, and spurious wake-ups (a thread returning from wait() with no notification at all) do happen. Being woken does not prove the condition holds.
CDK also notes that a Java monitor has only one implicit condition variable per object. Producers waiting for "not full" and consumers waiting for "not empty" therefore sit in the same wait set, and notify() might wake a thread of the wrong kind. That is why notifyAll() is used when producers and consumers wait on the same object.
The producer–consumer (bounded-buffer) problem
The classic use of these methods is the producer–consumer problem. Producers add items to a fixed-size buffer and consumers remove them. A producer must wait while the buffer is full, and a consumer must wait while it is empty. Buyya's version is a MessageQueue with three producers and three consumers. CDK's worker-pool server (section 4.8) uses the same pattern: worker threads wait on an empty request queue, and the I/O thread notifies them when it adds a request.
import java.util.LinkedList;
import java.util.Queue;
class BoundedBuffer {
private final Queue<String> items = new LinkedList<>();
private final int capacity;
BoundedBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(String msg) throws InterruptedException {
while (items.size() == capacity) wait(); // full: release lock until a consumer takes
items.add(msg);
notifyAll(); // wake consumers waiting on "empty"
}
public synchronized String take() throws InterruptedException {
while (items.isEmpty()) wait(); // empty: release lock until a producer puts
String msg = items.remove();
notifyAll(); // wake producers waiting on "full"
return msg;
}
}
// Usage: new Thread(() -> { ... buf.put("MSG#" + i) ... }).start(); // producer
// new Thread(() -> { ... buf.take() ... }).start(); // consumer
itemsis the shared queue andcapacityits fixed size. Both methods aresynchronized, so the buffer object is the monitor and only one thread at a time touchesitems.put: while the buffer is full,wait()releases the lock so a consumer can get in and take something. Once there is room it adds the message and callsnotifyAll()to wake any consumer waiting for "not empty".takeis the mirror image: wait while empty, remove an item,notifyAll()to wake any producer waiting for "not full", and return the item.- Both methods declare
throws InterruptedExceptionbecausewait()can be interrupted. - The usage lines show producer and consumer threads written as lambdas sharing one
buf.
The stepper runs this buffer with one producer and two consumers, and shows why the while loop matters.
- The buffer (capacity 2) is empty and its lock is free. One producer P and two consumers, C1 and C2, are running outside the monitor.
- C1 calls
take(), acquires the lock and enters.items.isEmpty()is true, so thewhileloop callswait(). wait()releases the lock and moves C1 into the wait set. The monitor is empty again, so a producer can get in.- P calls
put("m1"), acquires the lock, adds m1 and callsnotifyAll(). Meanwhile C2 callstake()and is blocked in the entry set, because P holds the lock. - The notification moves C1 from the wait set to the entry set: it is awake but must reacquire the lock before
wait()can return. P returns fromputand releases the lock. Now C2 and C1 both compete for it. - C2 happens to win the lock. The buffer is not empty, so it removes m1, calls
notifyAll()and returns. The buffer is empty again. - Now C1 gets the lock and returns from
wait(). Because it is in awhileloop, it re-tests the condition, finds the buffer empty and waits again. Had the code saidif, C1 would have gone straight on toitems.remove()on an empty queue and thrown an exception. Being notified did not mean the condition still held. - Later P puts m2 and notifies; C1 reacquires the lock, re-tests (not empty this time), removes m2 and returns. Both consumers got exactly one item, and no item was lost or taken twice.
sleep vs wait exam
These two are easy to confuse because both make a thread stop for a while. The difference is all about the lock.
sleep(ms) | wait() | |
|---|---|---|
| Belongs to | Thread (static method) | Object (any object) |
| Lock needed to call it? | No | Yes, the object's lock; otherwise IllegalMonitorStateException |
| Lock while paused | Keeps any locks it holds | Releases the object's lock |
| Wakes up when | The time expires (or it is interrupted) | Notified, interrupted or timed out; then reacquires the lock |
| State | Sleeping (TIMED_WAITING) | Waiting (WAITING) |
Check yourselfWhy must wait() release the lock? What would happen in the bounded buffer if it did not?
A consumer waits because the buffer is empty, and only a producer can change that. The producer must hold the same lock to run put. If the waiting consumer kept the lock, the producer would be blocked for ever and the consumer would never be woken: the system would hang. Releasing the lock while waiting is what lets the condition change.
Check yourselfWhich statement is true?
sleep() releases the lock so other threads can enter.wait() may be called from any method, synchronized or not.- After
notify(), the woken thread continues immediately, before the notifier leaves the synchronized code. - A thread woken from
wait() must reacquire the lock before wait() returns.
sleep() releases the lock so other threads can enter.wait() may be called from any method, synchronized or not.notify(), the woken thread continues immediately, before the notifier leaves the synchronized code.wait() must reacquire the lock before wait() returns.D. A is wrong: sleep keeps its locks. B is wrong: calling wait without holding the lock throws IllegalMonitorStateException. C is wrong: the notifier still holds the lock, so the woken thread waits in the entry set until it can reacquire it.
Check yourselfExplain wait/notifyAll with producer–consumer. Why use while? How do sleep and wait differ? (Guide self-test Q7)
In the synchronized put, the producer runs while (full) wait();, then adds the item, then calls notifyAll(). In take, the consumer runs while (empty) wait();, then removes an item, then calls notifyAll(). The while is there because the condition must be re-tested after waking: the state may have changed, and spurious wake-ups occur. As for the difference, wait must hold the lock when called and releases it, whereas sleep keeps its locks.
wait()(holding the lock) releases it and blocks until notified;notify()wakes one waiter,notifyAll()all; a woken thread reacquires the lock before continuing.- Always
while (!cond) wait();: the state may change before you get the lock back, and spurious wake-ups happen. - One condition variable per Java monitor, so use
notifyAll()when producers and consumers share it. sleepkeeps locks and needs none;waitneeds the lock and releases it.
Read–Write Locks, Granularity and Deadlock
Plain synchronized is correct but blunt. Assignment 1, the multi-threaded Subject Enrolment Server, raises three issues that go beyond it: letting readers share access, deciding how many locks to use, and making a check and its update atomic. Using several locks then brings a new danger, deadlock. This section covers all four, ending with the code that solves them together.
Readers and writers exam
Many readers can safely run together, because reading does not change anything; only a writer needs exclusive access. But synchronized lets in one thread at a time, so it serialises readers needlessly. This is the readers–writers problem. The class java.util.concurrent.locks.ReentrantReadWriteLock solves it: it allows either many holders of the read lock or one holder of the write lock, never both.
Unlike intrinsic locks, explicit locks such as ReentrantReadWriteLock are not released automatically when a method exits. If an exception is thrown between lock() and unlock(), the lock stays held for ever. So always write lock(); try { … } finally { unlock(); }; the finally block runs however the try block exits.
Lock granularity exam
Granularity means how much data each lock protects. A single global lock over the whole server is simple and cannot deadlock, but every operation is serialised, so operations on different subjects block one another. One lock per resource (per subject, or per bank account) lets independent operations run in parallel.
In general, finer granularity gives more concurrency, but it means more locks to manage and brings a deadlock risk whenever one operation needs several locks, such as a transfer between two subjects.
The check-then-act race exam
Consider if (enrolled < capacity) enrolled++;. It looks safe because it checks first. But with one seat left, two threads can both pass the check before either increments, and both enrol, oversubscribing the subject. The check and the update must therefore happen inside one critical section, under the write lock.
A check made under a read lock and followed by a write is still racy: two readers can check at the same time. And ReentrantReadWriteLock cannot upgrade a read lock to a write lock: a thread that holds the read lock and asks for the write lock waits for all readers to leave, including itself, so it deadlocks.
Putting it together: the EnrolmentService
The code below applies all three ideas, with one read–write lock per subject. query takes the read lock, enrol does its check and update under the write lock, and transfer takes two write locks in a fixed global order, which is the deadlock fix explained next.
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Subject {
final String code; final int capacity; int enrolled;
final ReentrantReadWriteLock rw = new ReentrantReadWriteLock(); // one lock PER subject
Subject(String code, int capacity) { this.code = code; this.capacity = capacity; }
}
class EnrolmentService {
int query(Subject s) {
s.rw.readLock().lock(); // shared: concurrent queries allowed
try { return s.enrolled; }
finally { s.rw.readLock().unlock(); } // always released, even on exception
}
boolean enrol(Subject s) {
s.rw.writeLock().lock(); // exclusive: no readers or writers
try {
if (s.enrolled >= s.capacity) return false; // check ...
s.enrolled++; // ... and act atomically
return true;
} finally { s.rw.writeLock().unlock(); }
}
boolean transfer(Subject from, Subject to) {
// Global lock order (ascending code), whatever the transfer direction
Subject first = from.code.compareTo(to.code) < 0 ? from : to;
Subject second = (first == from) ? to : from;
first.rw.writeLock().lock();
try {
second.rw.writeLock().lock();
try {
if (to.enrolled >= to.capacity) return false;
from.enrolled--; to.enrolled++;
return true;
} finally { second.rw.writeLock().unlock(); }
} finally { first.rw.writeLock().unlock(); }
}
}
Subjectholds the data plus its ownReentrantReadWriteLock rw: fine granularity, one lock per subject.queryonly reads, so it takes the read lock; many queries on one subject can run at once. Thereturninsidetrystill runs thefinally, so the lock is always released.enroltakes the write lock and does both the capacity check and the increment inside it, which closes the check-then-act race. Returningfalseearly is safe becausefinallystill unlocks.transferneeds two subjects. It works out which code is smaller withcompareToand always locks that subjectfirst, regardless of which is the source. Two nestedtry/finallyblocks release the two locks in reverse order.
Deadlock exam
Deadlock occurs when each thread in a set waits for ever for a lock held by another thread in the set. Suppose client 1 runs TRANSFER(X→Y) and client 2, at the same time, runs TRANSFER(Y→X), and each locks in request order, source then destination. Step through it.
- Two clients' threads: Thread 1 runs TRANSFER(X→Y) and Thread 2 runs TRANSFER(Y→X). Each needs the write locks of both subjects. A solid arrow from a lock means "held by"; a dashed red arrow from a thread means "waits for".
- Thread 1 locks its source, X.
- At the same time Thread 2 locks its source, Y.
- Thread 1 now asks for its destination, Y. Y is held by Thread 2, so Thread 1 waits, still holding X.
- Thread 2 asks for X, held by Thread 1, and waits too. The arrows form a cycle: each thread waits for a lock the other holds. Neither can ever proceed: deadlock.
- The fix: lock in one global order, ascending subject code, so X before Y, whatever the transfer direction. Thread 1 takes X. Thread 2's transfer is Y→X, but it must still take X first; X is taken, so it waits while holding nothing.
- Thread 1 takes Y too, does its transfer and releases both locks. Nothing could block it, because nobody holds Y while waiting for X.
- Thread 2 now takes X, then Y, and completes. With everyone climbing the same ladder in the same direction, a cycle cannot form.
Coffman identified four necessary conditions for deadlock. All four must hold at once for a deadlock to happen, so you can avoid deadlock by breaking any one of them.
Only one holder
A resource can be held by only one thread at a time.Hold one, want another
A thread holds one lock while waiting for another. Break it: acquire all locks at once, ortryLock(timeout) and back off.Locks can't be taken away
Only the holder can release a lock. Break it:tryLock(timeout), which gives up its own locks if it cannot get the next one.A cycle of waiters
Each thread waits for the next in a cycle. Break it: global lock ordering, astransfer does. This is what Assignment 1 requires.Two more habits help: hold locks only briefly, and never block on I/O or call remote code while holding a lock. And avoid suspend(), which freezes a thread while it still holds its locks (section 4.4).
Two related liveness problems are worth naming. In starvation, a thread never gets the lock because others keep getting it first. In livelock, threads keep reacting to each other (for example, both backing off and retrying in step) without making progress, so they are not blocked but get nowhere.
Check yourselfTwo clients simultaneously ENROL into the last seat, and two others TRANSFER X→Y and Y→X. What can go wrong, and how do you prevent it? (Guide self-test Q8)
ENROL is a check-then-act operation, so both clients could pass the check and take the last seat. The fix is to do the check and the increment under the subject's write lock, so exactly one enrol succeeds. The two TRANSFERs can deadlock if each locks in request order, because that creates a circular wait; the fix is to acquire both write locks in ascending subject code order. Using a per-subject ReentrantReadWriteLock means queries run concurrently and different subjects never block each other. In every case, unlock in finally.
Check yourselfList the four conditions for deadlock and two ways to avoid it. (Guide self-test Q9)
The four conditions are mutual exclusion, hold-and-wait, no preemption and circular wait. Deadlock can be avoided by global lock ordering (breaks circular wait), or by tryLock with a timeout and back-off, or acquiring all locks at once (breaks hold-and-wait / no preemption).
Check yourselfWhich change gives the most concurrency for an enrolment server where most requests are queries?
- One
synchronized global lock - A
ReentrantReadWriteLock per subject - A
ReentrantReadWriteLock for the whole server - No locks, because queries do not write
synchronized global lockReentrantReadWriteLock per subjectReentrantReadWriteLock for the whole serverB. Per-subject locks let operations on different subjects proceed in parallel, and the read lock lets many queries on the same subject overlap. C still serialises all writes across all subjects. D is unsafe: a query running during an enrol can see an inconsistent value, so readers still need the read lock.
ReentrantReadWriteLock: many readers or one writer; alwaysunlock()infinally.- Finer locks (per subject) give more concurrency but risk deadlock when an operation needs several locks.
- Check-then-act must be one critical section under the write lock; a read lock cannot be upgraded.
- Deadlock needs all four Coffman conditions; break circular wait with a global lock order (ascending subject code).
Multithreaded Server Architectures
A server's job is to answer as many requests as possible, as quickly as possible. A single-threaded server handles one request at a time: while it waits for the disk, the CPU sits idle and every new request waits in the queue at the server's port. Threads fix that, and there are four standard ways to organise them. This section explains why threads help, measures how much, and then compares the four designs, which is one of the most examined topics in the subject.
Why multithread a server? exam
Multithreading lets a server maximise throughput, measured as requests processed per second, in two ways: by overlapping computation with I/O (one thread computes while another waits for the disk), and by using multiple processors. It also lets the server give requests different priorities. A single-threaded server, by contrast, makes every new request wait in the port queue until the current one finishes.
Worked example: how much faster? (CDK §7.4.3) exam
CDK's worked example puts numbers on it. Each request needs 2 ms of CPU plus 8 ms of disk I/O, and there is one disk, so disk accesses happen one at a time. First compare one thread with two on a single CPU.
Every row of CDK's table follows the same three-step method:
- For each resource, work out its capacity as 1000 ÷ (ms of that resource per request), in requests per second.
- With overlap (several threads), throughput is the minimum of these capacities, because the bottleneck resource sets the pace.
- With a single thread nothing overlaps, so add the times instead: throughput = 1000 ÷ (total ms per request).
Now add one improvement at a time and watch which resource becomes the bottleneck. A cache here is a copy of recently used disk data kept in memory; a 75% hit rate means three requests in four find their data there and skip the disk.
- 1 thread, 1 CPU. Nothing overlaps, so each request takes 2 + 8 = 10 ms: 100 req/s. The grey bars show what each resource could manage alone; they do not matter here because the single thread uses them one after the other.
- 2 or more threads, 1 CPU. While one thread waits for the disk another computes, so the times overlap and the slower resource wins: CPU 500/s, disk 1000 ÷ 8 = 125/s, throughput 125 req/s. The disk is the bottleneck (red).
- Add a cache with a 75% hit rate. Only 25% of requests touch the disk, so mean disk time is 0.25 × 8 = 2 ms (capacity 500/s). But searching the cache costs CPU, raising CPU time to 2.5 ms (capacity 400/s). Throughput 400 req/s, now CPU-bound.
- 2 CPUs, 2 threads. Each thread handles one request at a time, spending 2.5 ms of CPU and 2 ms of disk: 4.5 ms per request, or about 222/s per thread. Two threads give 2 × 1000 ÷ 4.5 ≈ 444 req/s.
- 2 CPUs, 3 or more threads. Now there are enough threads to keep a resource busy. The two CPUs could do 800/s, but the disk is bounded at 500/s, so throughput is 500 req/s.
| Configuration | Reasoning | Max throughput |
|---|---|---|
| 1 thread, 1 CPU | 2 + 8 = 10 ms per request; nothing overlaps | 100 req/s |
| 2+ threads, 1 CPU | CPU work overlaps I/O. The disk is the bottleneck: 1000/8 | 125 req/s |
| + cache, 75% hit rate | Mean I/O = 0.25 × 8 = 2 ms, so the disk could handle 500/s. But CPU time rises to 2.5 ms (cache search), so the CPU limit is 1000/2.5 | 400 req/s |
| + 2 CPUs, 2 threads | Each thread: 2.5 + 2 = 4.5 ms per request; 2 × 1000/4.5 | ≈444 req/s |
| + 2 CPUs, 3+ threads | The CPUs could do 800/s, but the disk is bounded at 500 | 500 req/s |
Check yourselfA file server has an 80% cache hit rate. Each request costs 5 ms CPU, plus 15 ms disk I/O on a miss. Estimate throughput (i) single-threaded, (ii) two threads on one CPU, (iii) two threads on two CPUs. (CDK Ex. 7.8, guide self-test Q12)
Only misses go to disk, so the mean disk time per request is 0.2 × 15 = 3 ms.
- (i) Single-threaded, nothing overlaps, so each request takes 5 + 3 = 8 ms, giving 125 req/s.
- (ii) Two threads on one CPU: the CPU limit is 1000/5 = 200 and the disk limit is about 333, so throughput is ≤ 200 req/s. The server is CPU-bound, and this is an upper bound, since both threads may sometimes wait.
- (iii) Two threads on two CPUs: each thread does 1000/8 = 125 req/s, so the total is ≈ 250 req/s. This is under both the CPU limit (400) and the disk limit (333).
The four architectures exam
CDK, following Schmidt, describes four ways to organise a server's threads (CDK Figs 7.5–7.6). They differ in when threads are created, where requests queue, and what has to be synchronised, so those are the three points to cover whenever you compare them. Each tab draws its architecture in the same frame, so you can flip between them: clients on the left, the server process in the dashed box, remote objects (the server's shared data) on the right. The amber thread is the I/O thread, which receives requests; green threads are workers, which process them.
A fixed pool of worker threads is created at start-up. An I/O thread ("receipt and queuing") reads requests from all sockets/ports and appends them to a shared request queue. Each idle worker removes the next request and processes it, so any worker may serve any client.
- Queueing: one shared queue between the I/O thread and the workers; requests wait there when all the workers are busy. To support priorities, use several queues, which the workers scan in decreasing priority order.
- Concurrency control: the queue is shared by the I/O thread and all the workers, so it must be a monitor (the producer–consumer pattern of section 4.6): add and remove are
synchronized, workerswait()when the queue is empty, and the I/O threadnotify()s them. Shared server data must also be locked, since any of the workers may touch the same object concurrently.
- No per-request thread-creation cost.
- A bounded number of threads protects the server from overload.
- Prioritising is easy (one queue per priority).
- Inflexible: the pool may be too small for the current arrival rate.
- A high level of switching between the I/O and worker threads as they manipulate the shared queue.
Here the I/O thread spawns a new worker per request. The worker processes the request against its remote object and then destroys itself.
- Queueing: none. Requests are handed directly to new threads.
- Concurrency control: the hand-off needs none, but shared server data must still be locked.
- Workers do not contend for a shared queue.
- Throughput is potentially maximised: there can be as many workers as there are outstanding requests.
- The overhead of creating and destroying a thread per request.
- The number of threads is unbounded under load.
A worker thread is created when a client connects and destroyed when the client closes the connection. In between, it serves all requests on that connection, sequentially. (In Java, the creating is done by the main thread's accept() loop, shown in amber.)
- Queueing: no shared request queue. Each client's pending requests wait in its own connection (the socket/TCP buffer, the operating system's holding area for bytes that have arrived but not been read) and are handled in arrival order by that client's thread.
- Concurrency control: within one connection none is needed, because its requests are serialised. But shared server state, such as the subject list, is accessed by different connection threads, so it must be synchronised, with
synchronizedor with per-resource read–write locks.
- Lower thread-management overhead than thread-per-request: one thread per connection, not per request.
- A natural fit for TCP sessions (Buyya's
MultiThreadMathServer, Assignment 1). - Per-client ordering is preserved.
- Clients may be delayed while one thread has several outstanding requests and another has no work.
- One thread per client, even for idle clients.
There is one worker thread per remote object. An I/O thread receives requests and puts each one on that object's per-object queue.
- Queueing: one queue per object.
- Concurrency control: each per-object queue must be synchronised between the I/O thread and that object's worker. In return, each object's state is touched only by its own worker, so the object itself needs little locking.
- Low thread-management overhead.
- The same delay imbalance as thread-per-connection: requests wait behind a busy object while other workers sit idle, so a "hot" object becomes a bottleneck.
Which creates how many threads? exam
Exam multiple-choice questions like to ask how many threads each architecture creates. The answer always follows from the name: what is a thread created per? Here is one illustrative scenario counted four ways.
| Worker pool | Per-request | Per-connection | Per-object | |
|---|---|---|---|---|
| Threads created | Once (N fixed) | Per request | Per connection | Per object |
| Queue | Shared (one per priority) | None | None: each connection's own stream | One per object |
| Synchronisation needed | Shared queue + shared data | Shared data | Shared data | Per-object queues |
| Overhead | Low; queue contention | Highest (create/destroy) | Low | Low |
| Main weakness | Fixed size; switching | Unbounded threads | Busy connection waits while others idle | Hot objects |
| Good for | Many short requests; bounding resources; priorities | Bursty, longer independent requests | Session-based TCP clients sending many requests | Objects with heavy serialised state |
Explain the difference between a worker pool multi-thread model and a thread-per-connection model, including what concurrency control and queueing is required in each case.
The question names its marking dimensions: for each model, say how threads are created, what queueing happens and what concurrency control is needed, then give the trade-off. The model answer from the guide follows.
Past exam 2016 Q6(a)Worker pool vs thread-per-connection: creation, queueing, concurrency control, trade-off.
- Worker pool. A fixed number of workers is created at start-up. An I/O thread receives requests from all connections and places them on a shared request queue, and any free worker takes the next one. Queueing: when all the workers are busy, requests wait in the shared queue (possibly one queue per priority). Concurrency control: the queue is shared by the I/O thread and all the workers, so access to it must be mutually exclusive (synchronized or a monitor), with workers
wait()ing on an empty queue and being notified on insert. Shared server data must also be locked, since any of the workers may access it concurrently. - Thread-per-connection. A thread is created when a client connects and destroyed when it disconnects, and it serves all of that client's requests sequentially. Queueing: there is no shared request queue; a client's requests queue on its own connection (the socket buffer) and are processed in order. Concurrency control: none is needed for the hand-off, but shared server state accessed by different connection threads must be synchronized (with locks or RW locks).
- Trade-off. The pool bounds the number of threads and avoids creation cost, but it has a fixed size, and it suffers queue contention and switching. Per-connection has low per-request overhead and preserves order, but it needs one thread per client, and a busy connection's requests wait while other threads idle.
Check yourselfCompare the worker-pool and thread-per-request architectures. (CDK Ex. 7.9, guide self-test Q11)
The worker pool has no creation cost per request, bounds the number of threads, and supports priorities through several queues. Its drawbacks are an inflexible size, and contention and switching around the shared queue. Thread-per-request needs no shared queue and can have as many workers as there are outstanding requests, so its throughput is potentially maximal. Its drawbacks are the create/destroy overhead on every request and an unbounded number of threads.
Check yourselfA server must survive bursts of thousands of short requests with limited memory and CPU. Which architecture?
- Thread-per-request
- Worker pool
- Thread-per-connection
- Thread-per-object
B, worker pool. Its fixed number of threads prevents resource exhaustion, and there is no creation cost per request. Thread-per-request would create thousands of threads during the burst. (This is the Week 4 Ed quiz question 3.)
Check yourselfIn which architecture does the remote object itself need the least locking, and why?
Thread-per-object. Each object's state is touched only by its own worker, so requests to one object are already serialised. The synchronisation moves to the per-object queue shared by the I/O thread and that worker.
Java: a thread-per-connection server
In Java, a thread-per-connection server takes only a few lines. The main thread listens on a TCP port and loops, accepting each connection and starting a new thread for it. Each handler thread then reads and answers its client's requests in order until the client closes the connection. TCP (Transmission Control Protocol) gives a reliable, ordered byte stream between two sockets (Part III); a ServerSocket waits for clients to connect, and accept() returns a new Socket for each one.
import java.io.*;
import java.net.*;
public class ThreadPerConnectionServer {
public static void main(String[] args) throws IOException {
try (ServerSocket listener = new ServerSocket(4444)) { // 1. listen on a TCP port
while (true) {
Socket client = listener.accept(); // 2. wait for a connection
new Thread(new ConnectionHandler(client)).start(); // 3. one thread per connection
}
}
}
}
class ConnectionHandler implements Runnable {
private final Socket socket;
ConnectionHandler(Socket socket) { this.socket = socket; }
public void run() {
try (Socket s = socket;
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), "UTF-8"));
PrintWriter out = new PrintWriter(new OutputStreamWriter(s.getOutputStream(), "UTF-8"), true)) {
String request;
while ((request = in.readLine()) != null) { // 4. many requests, handled in order
out.println("ECHO " + request); // 5. process (lock shared state!) and reply
}
} catch (IOException e) {
System.err.println("Client error: " + e.getMessage());
} // 6. client closed: thread ends
}
}
new ServerSocket(4444)listens on TCP port 4444. Thetry (…)form ("try-with-resources") closes it automatically at the end.listener.accept()blocks until a client connects, then returns aSocketfor that one connection.- The main thread wraps the socket in a
ConnectionHandler(aRunnable) and starts a new thread for it, then loops straight back toaccept()for the next client. This is the "per connection" moment. - In the handler's
run(), the streams are wrapped for text:BufferedReaderreads lines,PrintWriterwithtrueflushes after eachprintln.readLine()returns each request in order, and returnsnullwhen the client closes the connection. - Each request is processed and answered (here, just echoed). In a real server, any shared state touched here must be locked, because other handler threads run the same code at the same time.
- When the loop ends, try-with-resources closes the socket and streams,
run()returns, and the thread dies.
Buyya also calls socket.setSoTimeout(...), so that an idle client cannot hold a thread for ever: a read that waits longer than the timeout throws an exception and the handler can close the connection.
Thread pools with ExecutorService
For a practical worker pool, Java provides ExecutorService. Create ExecutorService pool = Executors.newFixedThreadPool(8);, replace step 3 with pool.execute(new ConnectionHandler(client));, and call pool.shutdown() at exit. The executor's internal queue and threads correspond to CDK's request queue and workers. There are variations: newCachedThreadPool() creates threads on demand and reuses idle ones, and submit() returns a Future, an object through which you can later collect the task's result.
ExecutorService pool = Executors.newFixedThreadPool(8);
// ... inside the accept loop, instead of new Thread(...).start():
pool.execute(new ConnectionHandler(client));
// ... at exit:
pool.shutdown();
If you put connection handlers into a fixed pool of 8, at most 8 clients are served at once, and the 9th waits until a connection closes.
Java 21 virtual threads (Executors.newVirtualThreadPerTaskExecutor()) are cheap user-level threads, which makes thread-per-request affordable (compare section 4.10).
- Threads raise throughput by overlapping CPU with I/O and using several CPUs. Capacity per resource = 1000 ÷ ms; with overlap take the minimum, with one thread add the times.
- Worker pool: N threads at start-up, one shared queue (a monitor). Per-request: a thread per request, no queue, highest overhead, unbounded. Per-connection: a thread per connection, requests queue in the connection. Per-object: a worker and a queue per object, hot objects bottleneck.
- Java:
accept()loop +new Thread(handler).start();ExecutorServicegives a pool, but a pool of connection handlers caps clients.
Threads in Clients; Threads vs Processes
Threads help clients as well as servers. A remote invocation (a call to a method on another machine) normally blocks the caller until the reply arrives, which can take many milliseconds. A client that has other useful work to do should not sit idle for that long.
A two-thread client
CDK (Fig. 7.5) gives the client two threads. Thread 1 computes results and puts them into buffers; Thread 2 takes them out and performs the blocking remote invocations. Thread 1 then blocks only when the buffers are full. It is the producer–consumer pattern of section 4.6, used inside a client.
Web browsers use the same idea, fetching several pages or images concurrently to hide latency (the delay before a reply starts to arrive). Part V covers concurrent invocations further.
Threads vs multiple processes exam
Several single-threaded processes could also overlap computation and I/O, so why use threads? Threads are preferred for two reasons:
- They are cheaper to create and manage (section 4.2).
- They share resources more efficiently, because they live in one execution environment. For example, threads of one server can share one file cache in memory, which separate processes could not do directly.
If components need protection from one another, though, use processes, or a type-safe language (one, like Java, that stops code from accessing memory it should not).
Check yourselfCompare processes and threads. (Guide self-test Q2)
A process is an execution environment (address space, sockets, files, semaphores) plus threads, whereas a thread is an activity with its own stack, registers and state. Threads are cheaper to create and much cheaper to switch, because there is no domain transition or TLB/cache flush. They also share data efficiently, but they are unprotected from each other. Processes, by contrast, are protected, but they communicate through costly IPC.
Check yourselfA design team wants several components of one server to run concurrently, but a bug in one component must not be able to corrupt another's data. Threads or processes?
Processes (or a type-safe language). Threads of one process share an address space and are not protected from each other, so a buggy thread can overwrite any shared object. Separate processes have separate, protected address spaces, at the cost of more expensive creation, switching and communication.
- Clients use threads to keep working while blocking invocations are in progress (CDK's two-thread client; browsers fetching in parallel).
- Threads beat multiple processes on cost and resource sharing; processes win when components need protection from each other.
Kernel-Level vs User-Level Threads
Who actually implements threads? Either the OS kernel does, or a library running inside the process does, and the kernel never knows. The choice affects how cheap threads are, how flexibly they can be scheduled, and, crucially, what happens when one thread blocks.
The kernel provides thread system calls and schedules each thread itself. Windows, Linux, Solaris, Mach and Mac OS X work this way.
A runtime library schedules threads inside a single-threaded process, and the kernel does not know they exist. So the library must use non-blocking I/O, and it timeslices using kernel timers and software interrupts.
The strengths of one are largely the weaknesses of the other. A trap here means switching into the kernel via a system call, which costs time; a page fault happens when a thread touches memory that is not currently in RAM, so the kernel must fetch it, blocking the caller.
| User-level threads | Kernel-level threads | |
|---|---|---|
| Switch / create | Cheap: no trap to the kernel | System call needed |
| Scheduling policy | Customisable per application (e.g. real-time multimedia) | Fixed kernel policy |
| Number of threads | Many more | Limited |
| Multiprocessor | Cannot use several CPUs for one process | Can |
| Blocking call / page fault | Blocks the whole process | Blocks only that thread |
| Priorities across processes | No single global scheme | One global scheme |
Hybrid schemes
Hybrid schemes try to combine the two. In Mach, user-level code gives scheduling hints to the kernel. In Solaris 2, a user-level scheduler maps user threads onto kernel threads, called lightweight processes (LWPs). This uses multiprocessors and switches cheaply, but if a kernel thread blocks, all the user threads assigned to it are stuck.
Scheduler activations
Scheduler activations, from Anderson et al., go further. The insight is that a user-level scheduler needs more than kernel threads to map onto: the kernel must also tell it about scheduling events, such as one of its threads blocking. The labour is divided like this: the kernel allocates virtual processors (VPs) to each process, and their number can change over time; the process's user-level scheduler assigns its READY threads to them. A scheduler activation (SA) is an upcall from the kernel to the user-level scheduler, that is, a call made by the kernel into user code (the reverse of a system call).
Events flow in both directions. The process tells the kernel when a virtual processor is idle and when a virtual processor is needed. The kernel, in turn, notifies the process of four events:
- Virtual processor allocated: the user-level scheduler runs a READY thread on it.
- SA blocked: the scheduler marks that thread BLOCKED and runs another thread on the notifying SA.
- SA unblocked: the scheduler returns the thread to READY.
- SA preempted: the scheduler puts the preempted thread back to READY and re-evaluates.
- The kernel has given process A one virtual processor, VP1. The user-level scheduler runs T1 on it; T2 and T3 are READY.
- With READY threads waiting, the process tells the kernel "virtual processor needed". The kernel allocates VP2 and upcalls "VP allocated"; the scheduler runs T2 on it.
- T1 makes a blocking I/O call. The kernel upcalls "SA blocked" on a fresh activation; the scheduler marks T1 BLOCKED and runs another thread, T3, on the notifying activation. The processor is not wasted, unlike with pure user-level threads.
- T1's I/O completes. The kernel upcalls "SA unblocked" and the scheduler returns T1 to READY, to be run when a VP is free.
- The kernel needs VP2 for another process. It preempts it and upcalls "SA preempted"; the scheduler puts T2 back to READY and re-evaluates what should run on the VPs it still has. (When the process has nothing to run, it tells the kernel a VP is idle, so the kernel can give it away.)
The result is both flexible, since any scheduling policy can be built on these events, and efficient, since no thread stays READY while a virtual processor is available.
Check yourselfCompare user-level and kernel-level threads. What do scheduler activations add? (Guide self-test Q13)
User-level threads give cheap switching, custom scheduling and many threads. On the other hand, they cannot use a multiprocessor, a blocking call or page fault blocks the whole process, and there is no global priority scheme. Kernel-level threads are the reverse on each point. With scheduler activations, the kernel allocates virtual processors and upcalls the user scheduler on allocated, blocked, unblocked and preempted events. This keeps user-level flexibility without the blocking problem.
Check yourselfA process uses pure user-level threads on a 4-core machine. One thread calls a blocking read() on a file. Which is true?
- Only that thread blocks; the others keep running on the other cores.
- The whole process blocks, and it was only ever using one core anyway.
- The thread library moves the blocked thread to a kernel thread.
- The kernel reschedules the other user threads.
B. The kernel does not know the user threads exist; it sees one process, which made a blocking call, so it blocks the process. And a user-level-threaded process can use only one CPU. D is wrong because the kernel cannot schedule threads it does not know about. That is why such libraries must use non-blocking I/O.
- Kernel-level threads: scheduled by the kernel; multiprocessor-capable; a block blocks only one thread; but creating and switching need system calls.
- User-level threads: cheap, customisable, numerous; but one block or page fault stalls the process and they cannot use several CPUs.
- Hybrids: Mach (hints), Solaris 2 (user threads on LWPs). Scheduler activations: kernel allocates virtual processors and upcalls on allocated / blocked / unblocked / preempted.
Self-Test and Tutorial 3 Answers
This section collects the guide's self-test questions not already placed after their topics, then the official Tutorial 3 answers and the Week 4 Ed practice quiz. Try each question before opening the answer. Questions answered earlier in the Part are listed at the end so you can find them.
Self-test
Q1Why are distributed systems built from middleware plus network OSs rather than distributed OSs?
A network OS has one image per node, and the nodes keep their autonomy. A distributed OS offers a single system image with transparent process placement, but no DOS is in general use. There are two reasons:
- Users have an investment in their applications, and emulating other OSs is slow and hard to maintain.
- Users want autonomy and performance on their own machines.
NOS plus middleware strikes the balance, combining autonomy with network-transparent access to services.
Q3Give the two ways to create a Java thread, and say which is preferable. What happens if main calls t1.run(); t2.run();?
You either extend Thread, or implement Runnable and pass the object to new Thread(r); in both cases you then call start(). Runnable is usually preferable, because the class can still extend another class, the task is kept separate from the thread, and it works with pools. If main calls run() directly, no threads are created, and both bodies run sequentially in main.
Q4Draw the Java thread life cycle.
Draw the diagram from section 4.4. In words: a thread begins in New and moves to Ready when start() is called, and from there it moves back and forth between Ready and Running. From Running it can go to Sleeping, Waiting or Blocked (through sleep, wait, or I/O or a lock). Each of these returns to Ready, on timeout or interrupt, on notify, or on I/O completion respectively. When run() ends the thread goes from Running to Dead, and a dead thread cannot be restarted.
Q5Threads A, B and C are started in that order. Must A run first?
No. start() only makes a thread ready, and scheduling and preemption are decided by the JVM/OS, so they vary between runs and between cores. If the order matters, enforce it with join, locks or wait/notify.
Q6What is a race condition? How does synchronized fix the bank account example?
A race condition is when the outcome depends on the interleaving of unsynchronized accesses. In the bank account, balance += x is a read-modify-write, so updates can be lost. Declaring the methods synchronized makes them use the object's monitor lock, so only one thread runs any of them at a time, which makes each method a critical section.
The other self-test questions sit with their topics: Q2 (processes vs threads) in 4.9; Q7 (wait/notifyAll) in 4.6; Q8 and Q9 (enrolment races, deadlock) in 4.7; Q10 (the 2016 past-exam question), Q11 and Q12 (architectures and throughput) in 4.8; Q13 (user-level vs kernel-level threads) in 4.10.
Tutorial 3 model answers (Week 4) official
These are the tutors' official answers from Ed, Tutorial 03 (Week 4), plus the Week 5 thread demos. Staff have confirmed that tutorial content is examinable.
T3-1What is a thread?
A thread is a piece of code that runs concurrently with other threads. Each thread is a statically ordered sequence of instructions, and threads are used to express concurrency on both single-processor and multiprocessor machines. Compared with processes, the threads of a process share one address space, so switching between them and communicating between them are normally cheap (section 4.2).
T3-2What is the life cycle of a thread?
The tutors draw a simpler five-state model than the lecture's:
Their Non-Runnable state merges the lecture's Sleeping, Waiting and Blocked states, and either model earns the marks. The Ed quiz uses these five names, though, so know them. Note that a thread leaving Non-Runnable goes back to Runnable, not straight to Running.
T3-3What is synchronous access to shared resources, and how is it achieved?
If one thread reads data while another updates the same data, the data can be left in, or seen in, an inconsistent state. You prevent this by synchronising access to the data, either by declaring methods synchronized (for example public synchronized void update() {…}) or by using a synchronized (obj) block. Only one thread at a time can then run synchronised code on that object (section 4.5).
T3-4Compare the worker pool architecture with the thread-per-request architecture.
The tutors' definitions. In the worker pool architecture, the server creates a fixed number of worker threads. An I/O thread puts arriving requests on a queue, and each is assigned to the next available worker. In the thread-per-request architecture, a new thread is created for each request and deallocated when the request is finished.
The strengths and weaknesses below fill in the comparison from section 4.8.
The worker pool's strengths are bounded resource use and no thread-creation cost per request, and it is useful in highly concurrent systems. Its weaknesses are that the shared queue needs synchronisation, and that a pool that is too small makes requests queue up during bursts.
Thread-per-request's strengths are that it is simple, it has no shared queue, and a request never waits for a free worker. Its weaknesses are the cost of thread creation and destruction on every request, and the fact that unbounded thread numbers can exhaust memory and CPU during bursts.
Code demos (Weeks 4–5). The demos covered sleep, join and synchronisation; a multithreaded socket server and client; threads created by extending Thread and by implementing Runnable; and resource locking with ReentrantReadWriteLock, which allows many readers or one writer (section 4.7).
Ed practice quiz (Week 4), with answers
| # | Scenario | Answer |
|---|---|---|
| 1 | Two threads call an unsynchronised withdraw() (check balance, then subtract) on the same account. | Both may read the same balance before either updates it, so the final balance is wrong. This is a check-then-act race, also called a lost update. |
| 2 | withdraw() is synchronized; thread A calls account1.withdraw() and thread B calls account2.withdraw(). | Both run concurrently. A synchronized instance method locks its own object (this), and these are two different objects. |
| 3 | Bursts of thousands of short requests with limited memory and CPU. | Worker pool. Its fixed number of threads prevents resource exhaustion. |
| 4 | A thread's run() finishes normally. | Terminated. |
| 5 | increment() is synchronized but reset() on the same object is not. | Not thread-safe. reset() doesn't acquire the lock, so it can run in the middle of increment() and interfere. Every method that touches the shared state must use the same lock. |
- A "compare" question wants named dimensions: for server architectures, say when threads are created, where requests queue and what must be synchronised, then the trade-off.
- For processes vs threads, quote CDK's four points: cheaper to create, much cheaper to switch, share easily, unprotected.
- For throughput, show the method: capacity = 1000 ÷ ms per resource; minimum with overlap, sum without.
- In code answers, put check-and-act inside one lock, unlock in
finally, wait in awhileloop, and lock in a global order.
- Tutorial 3's official answers: a thread is a concurrently running ordered instruction sequence sharing its process's address space; the five-state life cycle;
synchronizedmethods or blocks prevent inconsistent state; worker pool (fixed threads, queue) vs thread-per-request (new thread each, no queue). - The Ed quiz tests lost updates, per-object locks, pools under bursts, the Terminated state, and the unsynchronised-method back door.