Part IV

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.
4.1

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

Definition · Middleware

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.

NODE 1 NODE 2 NODE 3 Application component Application component Application component Middleware: one uniform layer across every node communication (RPC/RMI, events, groups) · management · application-specific services · runtime OS: Linux OS: Windows OS: macOS x86 server x86 PC ARM laptop Each node's platform (green OS + amber hardware) is different; the violet middleware band looks the same on every node.
Middleware is the only layer that crosses node boundaries. Each node keeps its own OS and hardware (its platform), so the platforms are heterogeneous. The middleware band runs across all of them and gives the application components one uniform set of services, whatever platform each one sits on.

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.

user NODE 1 NODE 2 NODE 3 job own OS own OS own OS ssh node2; run job three system images, one per node
Network OS. Each node runs its own OS image and manages its own processes. The user picks the node (here by logging in to node 2 with ssh, a remote login tool).
user NODE 1 NODE 2 NODE 3 load: high load: high job load: low one distributed OS: single system image run job (the OS picks the least-loaded node) one system image across all nodes
Distributed OS. One OS controls every node. The user does not choose; the OS places the job transparently, for example on the least-loaded node.
Network OS (NOS)Distributed OS (DOS)
System imageMultiple, one per nodeSingle system image
AutonomyEach node manages its own processesOne OS controls all nodes
Process placementUser chooses (e.g. ssh to a host and run there)OS places processes transparently (e.g. on the least-loaded node)
TransparencyOnly 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
ExamplesUNIX/Linux, Windows, Mac OSResearch 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.

  1. 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.
  2. 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?
  1. Linux is a distributed OS because it supports NFS.
  2. A distributed OS gives one system image per node.
  3. In a network OS the user decides on which node a process runs.
  4. 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.

Process manager creates processes and operates on them Communication mgr between threads of different processes on one computer Thread manager thread creation, synchronisation, scheduling Memory manager physical and virtual memory Supervisor dispatches interrupts, system-call traps and exceptions · controls the MMU, caches and registers hardware: CPU, memory, devices
Four managers sit on one supervisor. The supervisor is the only part that touches the hardware directly; Windows calls it the Hardware Abstraction Layer.
Process managerCreates processes and operates on them. A process is the unit of resource management: an address space plus threads.
Thread managerCreates threads, synchronises them and schedules them on the CPU.
Communication managerHandles communication between threads of different processes on the same computer. Some kernels also support remote communication.
Memory managerManages physical memory (the RAM chips) and virtual memory (the per-process view of memory).
SupervisorDispatches interrupts, system-call traps (the instruction a program uses to ask the kernel for a service) and exceptions, and controls the MMU (memory management unit, which translates addresses), the cache and the registers.
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).

In short
  • 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.
4.2

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.

one timeslice one CPU A B C A B C A B C A B C only one task runs at any instant: the switching gives an illusion of concurrency core 1 A C A C core 2 B time → two cores: two tasks really run at the same instant (true parallelism)
One CPU fakes concurrency; several cores provide it. A, B and C can be whole processes (multitasking) or threads of one process (multithreading); the picture is the same. On a single CPU the tasks only take turns, so true parallelism needs multiple cores.

Processes, execution environments and threads exam

Definitions

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 PROCESS = EXECUTION ENVIRONMENT + THREADS EXECUTION ENVIRONMENT · SHARED BY ALL ITS THREADS · UNIT OF RESOURCE MANAGEMENT · PROTECTION DOMAIN Address space code (text) heap: objects, with their instance and static fields Synchronisation & comms semaphores sockets Higher-level resources open files windows PRIVATE TO EACH THREAD Thread 1 stack: local variables registers + program counter priority execution state Thread 2 stack: local variables registers + program counter priority execution state Thread 3 stack: local variables registers + program counter priority execution state
Everything in the grey zone is shared; each coloured box is private. This is the most useful picture in the Part. Because objects (and so their instance and static fields) live on the shared heap, two threads can reach the same field, which is where race conditions come from (section 4.5). Local variables live on a thread's own stack, so they are never shared. Throughout this Part, threads are coloured blue, red and green to tell them apart.

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.

ONE PROCESS · ONE ADDRESS SPACE Thread 1 Thread 2 switch save thread 1's registers, load thread 2's same address space: nothing to flush ≈ 0.4 ms via the kernel ≈ 0.04 ms for user-level threads
Thread switch: cheap. Only the registers change hands.
PROCESS 1 PROCESS 2 Thread 1 Thread 2 switch address space 1 address space 2 domain transition: new address space, flush TLB and caches ≈ 1.8 ms
Process switch: expensive. The address space changes too, so cached translations must be thrown away.

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.

AspectProcessThread
Address spaceOwn, protectedShared with sibling threads
Creation costHigh: 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 costHigh: domain transition, TLB/cache flush (about 1.8 ms)Low: about 0.4 ms via the kernel, 0.04 ms at user level
CommunicationIPC (interprocess communication) via the kernel: pipes, socketsShared variables
ProtectionProtected from other processesNot protected from sibling threads
CREATION create a process 11 ms create a thread 1 ms SWITCHING switch between processes 1.8 ms switch threads (kernel) 0.4 ms switch threads (user level) 0.04 ms CDK's figures from the Topaz kernel: rough guides only
Drawn to the same scale. A thread is about ten times cheaper to create than a process, and switching between threads is several times cheaper again. The exact numbers are not the point; the ordering is.

CDK sums up the comparison in four points, which are worth quoting as they stand in an exam answer:

  1. Creating a thread is cheaper than creating a process.
  2. More importantly, switching between threads of the same process is cheaper than switching between processes.
  3. Threads share data and resources conveniently and efficiently.
  4. 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.

P1's address space virtual 0x4000 Physical memory frame 12: P1 data frame 20: … frame 37: P2 data P2's address space virtual 0x4000 TLB entry: 0x4000 → frame 12 correct for P1, wrong after a switch to P2: so it must be flushed
Same address, different data. Within one process all threads share one translation, so switching threads keeps the TLB valid. Switching processes changes what every virtual address means.

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.

text (code) heap free shared libraryor mapped file free stack T2 stack T1 kernel grows grows shared region one stack per thread shared address 0 top address
Regions with gaps between them. The heap and the stacks grow towards each other into free space. Each thread gets its own stack region, which is why local variables are private.

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.

fork() with copy-on-write, one step at a time
PARENT page 0 page 1 page 2 PHYSICAL MEMORY frame A frame B frame C free frame frame D = copy of C CHILD (AFTER FORK) page 0 page 1 page 2 child writes page 2: page fault child's page 2 now maps to D
  1. The parent process maps its pages 0, 1 and 2 to physical frames A, B and C.
  2. 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).
  3. The child writes to page 2. Because frame C is write-protected, the hardware raises a page fault and the kernel takes over.
  4. 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.

main start() ×3 join() ×3 sin(x) cos(x) tan(x) sum the results
Fork, compute in parallel, join. Each coloured line is one thread. join() makes main wait until the thread it is called on has terminated, so the sum uses three finished results.
1 · Parallel tasksIndependent tasks run in parallel, like sin, cos and tan above.
2 · Reactive UIsA user interface stays responsive while other threads do the slow work.
3 · Non-blocking I/OComputation overlaps with I/O (input/output, such as disk or network transfers): while one thread waits for I/O, another computes.
4 · Asynchronous behaviourTimers and alarms run in their own threads.
5 · ServersA server serves many clients at once. A web or FTP server's main thread loops: wait for a request, create a worker, start it.
6 · CooperationThreads cooperate on one job, e.g. a parallel file copy in which reader and writer threads lock shared buffers.
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?
  1. Its open sockets
  2. Its address space
  3. A thread's program counter
  4. 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.

In short
  • 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 fork share frames until someone writes.
4.3

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 Thread with its own run(): the body is what the new thread will execute, here a loop printing five lines and then "Exit from A".
  • class Task implements Runnable: its run() prints the name of whichever thread is executing it, found with the static method Thread.currentThread().
  • A a = new A(); creates a Thread object in the New state. It is just an object; no thread is running yet.
  • new Thread(new Task()) wraps the task in a Thread object, which will call the task's run() 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 call a.run(). start() returns immediately, so main carries straight on to t.start().
  • a.join(); t.join(); make main wait until each thread has terminated. join() can throw InterruptedException (if another thread interrupts the waiting one), which is why main declares throws InterruptedException.
Thread + start() + run() + join() A + run() overrides A IS a thread new A().start();
Way 1: extends Thread. The hollow triangle means "is a kind of". A inherits start() and overrides run().
Thread + start() + run() + join() «interface» Runnable + run() target Task + run() a task, not a thread new Thread(new Task()).start();
Way 2: 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 Threadimplements Runnable
Object isA threadA unit of work, run with new Thread(r) (or a lambda)
InheritanceUses up Java's single inheritanceCan still extend another class (e.g. MultiThreadMathServer extends MathServer implements Runnable)
ControlFull control over the thread life cycleDefines only the work
Use whenYou need to customise the thread itselfUsually 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.

th1.run(); th2.run(); main th1's body th2's body no new thread: two ordinary method calls in main th2's body starts only after th1's body ends
Wrong: run(). Both bodies execute sequentially in main.
th1.start(); th2.start(); main start() start() th2 runs run() th1 runs run() three threads now run concurrently
Right: 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.

In short
  • Two ways: extends Thread (object is a thread) or implements Runnable passed to new Thread(r) (object is a task). Prefer Runnable.
  • start() creates a new thread that later calls run(); calling run() yourself is a plain method call in the current thread.
  • A thread can be started once; a dead thread cannot be restarted.
4.4

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).

ALIVE, BUT NOT RUNNING NewNEW start() ReadyRUNNABLE dispatched by the scheduler yield() or timeslice expired RunningRUNNABLE run() completes or stop() DeadTERMINATED SleepingTIMED_WAITING WaitingWAITING BlockedBLOCKED sleep(ms) wait() I/O or suspend() time expired or interrupted notify() / notifyAll() I/O done or resume()
Every way out of a waiting state leads back to Ready, never straight to Running. The thread must be dispatched by the scheduler again. The small capitals under each name are Java's own 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' stateJava Thread.StateNotes
NewNEWCreated, not yet started
Ready + RunningRUNNABLEJava does not distinguish "waiting for a CPU" from "on a CPU"
BlockedBLOCKEDIn Java, means waiting for a monitor lock (4.5); the slides also include blocking on I/O
WaitingWAITINGEntered through wait() or join()
SleepingTIMED_WAITINGEntered through sleep(ms)
DeadTERMINATEDrun() 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 vs the slides

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.

MethodWhat it doesWatch out
sleep(ms) (static)Pauses the current thread for at least ms millisecondsKeeps its locks while it sleeps; throws InterruptedException
yield() (static)A hint that the running thread should move back to ReadyOnly 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 expiresThrows 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 InterruptedExceptionIf the target is not in one of those calls, it just sets a flag the thread can check
setPriority, getPrioritySet and read the priority, from 1 to 10Only a hint to the scheduler
setDaemon(true)Makes it a background (daemon) thread; the JVM exits when only daemons remainMust be called before start(). A daemon is not the same as a low-priority thread
stop(), suspend(), resume()Kill, freeze and unfreeze a threadDeprecated, 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.

T1 stop() shared account pair from: −50 applied to: +50 never applied T2 lock released: T2 sees the half-done update
stop() releases all locks mid-update. Objects the thread was halfway through changing are left inconsistent and visible to other threads.
lock L T1suspended T2blocked held by waits for resume() T2 must get L before it can call T1.resume() deadlock
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.

The safe alternatives

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();
    }
}
run 1 A: i = 1..5 Exit A C: i = 1..5, exit B: i = 1..5, exit run 2 A: i = 1..5 C: i = 1..5, exit B: i = 1..5, exit Exit A A preempted here, before its last println printed last order of output →
Same program, two different outputs. In run 2, A was preempted after its loop but before its final 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.

The lesson

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.

the scheduler can switch at any instruction (timer interrupts) preemptive T1 T2 T1 T2 T1 T2 shared data must be synchronised · in return, threads can use multiprocessors yield() system call non-preemptive T1 runs until it calls the threading system T2 T1 code between such calls is automatically a critical section: no races but no multiprocessor, long computations need explicit yield(), unsuited to real-time work
Who decides when to switch? Under preemptive scheduling (top) the scheduler can interrupt a thread anywhere. Under non-preemptive or coroutine scheduling (bottom) a thread runs until it calls into the threading system. Java threads behave preemptively in practice.
Preemptive

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.

Non-preemptive (coroutine)

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?
  1. Running
  2. Ready
  3. Waiting
  4. 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().

In short
  • Life cycle: New → (start) Ready ⇄ Running → Sleeping / Waiting / Blocked → back to Ready; Running → Dead when run() ends. Dead threads cannot restart.
  • sleep keeps locks; yield is a hint; join waits for termination; interrupt wakes sleep/wait/join. stop/suspend/resume are 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.
4.5

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.

A lost update, one instruction at a time
Thread 1 deposit(30) shared field balance Thread 2 withdraw(50) 100 100 1 · read balance → 100 r = 100 100 2 · read balance → 100 r = 100 100 3 · r = 100 + 30 = 130 100 4 · r = 100 − 50 = 50 130 5 · write 130 50 6 · write 50 final balance 50, but 100 + 30 − 50 = 80 thread 1's write was overwritten: the deposit of 30 is lost
  1. The shared field balance holds 100. Thread 1 is about to run balance += 30 and Thread 2 balance -= 50. Each statement is really read, compute, write.
  2. Thread 1 reads balance into its own register: r = 100. Then the scheduler preempts it.
  3. Thread 2 runs and also reads 100. Both threads now hold a private copy of the same old value.
  4. Thread 1 resumes and computes 100 + 30 = 130 in its register. Memory still says 100.
  5. Thread 2 computes 100 − 50 = 50, also from the stale 100.
  6. Thread 1 writes 130. For a moment the balance looks right for a deposit.
  7. Thread 2 writes 50, overwriting 130. Thread 2 never saw the deposit.
  8. 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.

Definitions

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.

ENTRY SET · BLOCKED T2 wants the lock T3 wants the lock an object (the monitor) intrinsic lock T1: the owner runs synchronized code at most one thread inside WAIT SET · WAITING T4 called wait() acquire wait() releases lock leave: lock released automatically notify() / notifyAll(): back to the entry set, must reacquire the lock
One owner inside, everyone else waits in one of two rooms. The entry set holds threads blocked trying to get the lock. The wait set holds threads that called 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 like balance += amt, so without the lock two threads can lose increments.
  • The lambda task is a Runnable; both threads run the same task on the same Counter c, which is what makes count shared.
  • get() is synchronized too, so it reads a consistent value.
  • join() makes main wait for both threads before printing. Remove synchronized and the total is usually less than 200,000.

Here is the same deposit/withdraw race again, this time with both methods synchronized.

The same two threads with synchronized methods
time Thread 1 deposit(30) Account object balance + lock Thread 2 withdraw(50) 100 · lock free 1 · enter deposit() 100 · lock: T1 2 · read → 100 100 · lock: T1 3 · enter withdraw() 100 · lock: T1 BLOCKED: lock held by T1 r = 130 4 · write 130 130 · lock: T1 5 · return: unlock 130 · lock: T2 T2 acquires the lock 6 · read → 130 130 · lock: T2 r = 130 − 50 = 80 7 · write 80, unlock 80 · lock free final balance 80 = 100 + 30 − 50: correct
  1. Same start: balance is 100 and the account's lock is free. Now deposit and withdraw are both synchronized.
  2. Thread 1 calls deposit(30) and acquires the account's lock on entry.
  3. Thread 1 reads 100. Suppose it is preempted right here, exactly as before.
  4. 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.
  5. Thread 1 resumes, computes 130 and writes 130, still holding the lock.
  6. Thread 1 returns from deposit, which releases the lock automatically. Thread 2 is woken and acquires it.
  7. Thread 2 now reads 130, the up-to-date value, and computes 80.
  8. Thread 2 writes 80 and returns, releasing the lock.
  9. 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.

account1 lock: held by T1 T1: withdraw() account2 lock: held by T2 T2: withdraw() at the same time T3: withdraw() BLOCKED: account1's lock is taken
One lock per object. T1 and T2 use different objects and so different locks, so both run. Only T3, which wants the same object as T1, has to wait.
Pitfall · mixing synchronized and unsynchronized access

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.

Counter count T1 and T2 inside at once T1: increment() synchronized: takes the lock T2: reset() not synchronized: no lock reset() can run in the middle of increment(): every method touching count must use the same lock
A back door defeats the lock. This is exactly the Week 4 Ed quiz question 5: if 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?
  1. B blocks until A returns.
  2. Both run concurrently.
  3. A IllegalMonitorStateException is thrown.
  4. They deadlock.

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.

In short
  • A race condition: the outcome depends on the interleaving. x += y is read, compute, write, so updates can be lost.
  • Protect each critical section with mutual exclusion. Fields are shared; locals are private.
  • synchronized uses the object's intrinsic (monitor) lock: one thread inside at a time; methods lock this (static: the Class), 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.
4.6

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

  1. A thread calls wait() on an object while holding that object's lock (inside synchronized code).
  2. wait() releases the lock and blocks the thread in the object's wait set until it is notified, interrupted or (with wait(ms)) timed out.
  3. Another thread, holding the same lock, calls notify() to wake one waiting thread, or notifyAll() to wake all of them. Woken threads then compete for the lock.
  4. 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.

Rule · always wait in a 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
  • items is the shared queue and capacity its fixed size. Both methods are synchronized, so the buffer object is the monitor and only one thread at a time touches items.
  • 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 calls notifyAll() to wake any consumer waiting for "not empty".
  • take is 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 InterruptedException because wait() 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.

Producer–consumer on a monitor, step by step
OUTSIDE ENTRY SET · BLOCKED BoundedBuffer · capacity 2 lock: free lock: held by C1 lock: held by P lock: held by C2 m1 items WAIT SET · WAITING P · producer P · producer C1 · consumer C1 · consumer C1 · consumer C1 · consumer C2 · consumer C2 · consumer C2 · consumer C2 got m1 C1 got m2 isEmpty() is trueso C1 calls wait():lock released items.add("m1")notifyAll() empty again inside:P has returned not empty, soitems.remove() → m1 C1 re-tests the while:still empty, so wait() again(with if: remove() on anempty queue throws) P added m2 and notified;C1 re-tests: not empty,remove() → m2
  1. 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.
  2. C1 calls take(), acquires the lock and enters. items.isEmpty() is true, so the while loop calls wait().
  3. wait() releases the lock and moves C1 into the wait set. The monitor is empty again, so a producer can get in.
  4. P calls put("m1"), acquires the lock, adds m1 and calls notifyAll(). Meanwhile C2 calls take() and is blocked in the entry set, because P holds the lock.
  5. 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 from put and releases the lock. Now C2 and C1 both compete for it.
  6. C2 happens to win the lock. The buffer is not empty, so it removes m1, calls notifyAll() and returns. The buffer is empty again.
  7. Now C1 gets the lock and returns from wait(). Because it is in a while loop, it re-tests the condition, finds the buffer empty and waits again. Had the code said if, C1 would have gone straight on to items.remove() on an empty queue and thrown an exception. Being notified did not mean the condition still held.
  8. 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 toThread (static method)Object (any object)
Lock needed to call it?NoYes, the object's lock; otherwise IllegalMonitorStateException
Lock while pausedKeeps any locks it holdsReleases the object's lock
Wakes up whenThe time expires (or it is interrupted)Notified, interrupted or timed out; then reacquires the lock
StateSleeping (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?
  1. sleep() releases the lock so other threads can enter.
  2. wait() may be called from any method, synchronized or not.
  3. After notify(), the woken thread continues immediately, before the notifier leaves the synchronized code.
  4. A thread woken from 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.

In short
  • 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.
  • sleep keeps locks and needs none; wait needs the lock and releases it.
4.7

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.

READ LOCK HELD · MANY READERS AT ONCE subject COMP90015 reader: query() reader: query() reader: query() writer: enrol() waits WRITE LOCK HELD · ONE WRITER ONLY subject COMP90015 writer: enrol() reader: query() reader: query() reader: query() all wait
Many readers or one writer. Queries (blue) can overlap because they only read. An enrolment (red) changes the count, so while it runs no one else may read or write that subject.
Pitfall · explicit locks are not released for you

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.

all subjects one global lock COMP1 COMP2 COMP3 T1 → COMP1 T2 → COMP2 T2 waits although it wants a different subject
Coarse: one global lock. Simple and deadlock-free, but everything is serialised.
COMP1own lock COMP2own lock COMP3own lock T1 → COMP1 T2 → COMP2 different subjects proceed in parallel
Fine: one lock per subject. More concurrency, more locks to manage.

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.

T1: enrol() T2: enrol() 1 seat left: enrolled 99, capacity 100 check 99 < 100: true check 99 < 100: true enrolled++ → 100 enrolled++ → 101 101 students in 100 seats
Bad: check and act are separate. Both threads see a free seat before either takes it.
T1: enrol() T2: enrol() one critical section write lock 99 < 100: true enrolled++ → 100 unlock write lock: waits 100 < 100: false return false exactly one enrol succeeds
Good: check and act in one critical section. T2 can only check after T1's increment is visible.
Pitfall · checking under the read 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(); }
    }
}
  • Subject holds the data plus its own ReentrantReadWriteLock rw: fine granularity, one lock per subject.
  • query only reads, so it takes the read lock; many queries on one subject can run at once. The return inside try still runs the finally, so the lock is always released.
  • enrol takes the write lock and does both the capacity check and the increment inside it, which closes the check-then-act race. Returning false early is safe because finally still unlocks.
  • transfer needs two subjects. It works out which code is smaller with compareTo and always locks that subject first, regardless of which is the source. Two nested try/finally blocks 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 transfers: deadlock, then the lock-ordering fix
Locking in request order (source, then destination) Fix: every thread locks in one global order, X before Y lock X lock Y Thread 1TRANSFER(X→Y) Thread 2TRANSFER(Y→X) held by held by waits for waits for held by held by cycle: deadlock has X and Y: transfers done, both releasedhas X and Y: transfers holds nothing while waiting
  1. 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".
  2. Thread 1 locks its source, X.
  3. At the same time Thread 2 locks its source, Y.
  4. Thread 1 now asks for its destination, Y. Y is held by Thread 2, so Thread 1 waits, still holding X.
  5. 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.
  6. 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.
  7. Thread 1 takes Y too, does its transfer and releases both locks. Nothing could block it, because nobody holds Y while waiting for X.
  8. 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.

1 · Mutual exclusion

Only one holder

A resource can be held by only one thread at a time.
2 · Hold and wait

Hold one, want another

A thread holds one lock while waiting for another. Break it: acquire all locks at once, or tryLock(timeout) and back off.
3 · No preemption

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.
4 · Circular wait

A cycle of waiters

Each thread waits for the next in a cycle. Break it: global lock ordering, as transfer 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?
  1. One synchronized global lock
  2. A ReentrantReadWriteLock per subject
  3. A ReentrantReadWriteLock for the whole server
  4. No locks, because queries do not write

B. 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.

In short
  • ReentrantReadWriteLock: many readers or one writer; always unlock() in finally.
  • 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).
4.8

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.

SINGLE-THREADED SERVER R4 R3 R2 port queue the only thread serving R1 disk I/O while it waits for the disk, the CPU idles and R2 to R4 must wait their turn
The problem threads solve. One thread means nothing overlaps: the CPU and the disk take turns being idle, and requests queue at the port.

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.

ONE THREAD CPU disk R1 R2 R3 R4 R1 R2 R3 R4 nothing overlaps: one request every 2 + 8 = 10 ms → 100 req/s TWO THREADS, ONE CPU CPU disk R1 R2 R3 R4 R5 R6 R1 R2 R3 R4 R5 the disk never rests: one request every 8 ms → 1000 ÷ 8 = 125 req/s 0 10 20 30 40 ms
Overlap is the whole gain. Blue requests are handled by thread 1 and red by thread 2. With two threads the CPU work of one request hides inside the disk time of another, so the single disk becomes the bottleneck and sets the pace.

Every row of CDK's table follows the same three-step method:

  1. For each resource, work out its capacity as 1000 ÷ (ms of that resource per request), in requests per second.
  2. With overlap (several threads), throughput is the minimum of these capacities, because the bottleneck resource sets the pace.
  3. 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.

Throughput, one improvement at a time
CPU capacity disk capacity max throughput 0 200 400 600 800 req/s 1 thread, 1 CPU 500 (if it overlapped) 125 100 no overlap, so add the times: 1000 ÷ (2 + 8) = 100 req/s 2+ threads, 1 CPU 1000 ÷ 2 = 500 1000 ÷ 8 = 125: bottleneck 125 CPU work overlaps I/O; min(500, 125) = 125 req/s, set by the disk + cache with a 75% hit rate 1000 ÷ 2.5 = 400: bottleneck 1000 ÷ 2 = 500 400 mean disk time = 0.25 × 8 = 2 ms, but searching the cache raises CPU time to 2.5 ms min(400, 500) = 400 req/s, now set by the CPU + 2 CPUs, 2 threads 2 × 400 = 800 500 ≈ 444 each thread does its own CPU then disk work: 2.5 + 2 = 4.5 ms per request 2 threads × 1000 ÷ 4.5 ≈ 444 req/s: too few threads to saturate either resource + 2 CPUs, 3 or more threads 800 500: bottleneck 500 the CPUs could do 800/s, but the disk is bounded at 500: min(800, 500) = 500 req/s
  1. 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. 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).
  3. 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.
  4. 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.
  5. 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.
ConfigurationReasoningMax throughput
1 thread, 1 CPU2 + 8 = 10 ms per request; nothing overlaps100 req/s
2+ threads, 1 CPUCPU work overlaps I/O. The disk is the bottleneck: 1000/8125 req/s
+ cache, 75% hit rateMean 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.5400 req/s
+ 2 CPUs, 2 threadsEach thread: 2.5 + 2 = 4.5 ms per request; 2 × 1000/4.5≈444 req/s
+ 2 CPUs, 3+ threadsThe CPUs could do 800/s, but the disk is bounded at 500500 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.

C1 C2 C3 SERVER PROCESS N workers created once, at start-up (+ 1 I/O thread) I/O threadreceipt & queuing R4 R3 R2 R1 shared request queue a monitor: synchronized + wait/notify worker 1 worker 2 worker 3 object A object B shared data must be locked: any worker may touch any object
Worker pool. One I/O thread fills one shared queue; a fixed set of workers drains it. Any worker may serve any client and touch any object.

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, workers wait() when the queue is empty, and the I/O thread notify()s them. Shared server data must also be locked, since any of the workers may touch the same object concurrently.
Advantages
  • No per-request thread-creation cost.
  • A bounded number of threads protects the server from overload.
  • Prioritising is easy (one queue per priority).
Disadvantages
  • 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.
C1 C2 C3 SERVER PROCESS one new worker per request (+ 1 I/O thread) I/O threadspawns workers new Thread worker for R1 worker for R2 worker for R3 worker for R4 each replies, then destroys itself object A object B shared data must be locked
Thread-per-request. No queue at all: each request is handed straight to a brand-new thread, which dies after replying.

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.
Advantages
  • Workers do not contend for a shared queue.
  • Throughput is potentially maximised: there can be as many workers as there are outstanding requests.
Disadvantages
  • The overhead of creating and destroying a thread per request.
  • The number of threads is unbounded under load.
C1 C2 C3 SERVER PROCESS one thread per connection, for its lifetime R2 R1 R1 C1's own connection (TCP socket buffer) idle client: no requests thread for C1 thread for C2 thread for C3 main: accept() loop creates a thread when a client connects object A object B shared server state must be locked
Thread-per-connection. Each client gets a dedicated thread for the whole connection. Its pending requests wait in its own connection, not in a shared queue, and are served in order.

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 synchronized or with per-resource read–write locks.
Advantages
  • 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.
Disadvantages
  • Clients may be delayed while one thread has several outstanding requests and another has no work.
  • One thread per client, even for idle clients.
C1 C2 C3 SERVER PROCESS one worker per remote object (+ 1 I/O thread) I/O threadroutes by object R5 R3 R1 queue for A R4 R2 queue for B each queue synchronised between the I/O thread and that object's worker worker for A worker for B object A object B only its own worker touches each object
Thread-per-object. Requests are sorted by target object; one worker per object serves that object's queue. Object A, with three requests waiting, is the "hot" one.

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.
Advantages
  • Low thread-management overhead.
Disadvantages
  • 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.

Scenario: 3 clients connected, each with 2 requests in flight (6 requests), on 2 remote objects; pool size N = 4 Worker pool Thread-per-request Thread-per-connection Thread-per-object 4 + 1 6 + 1 3 2 + 1 N workers, created once at start-up, fixed whatever the load one per request in flight each destroyed after its reply one per open connection even an idle one (Java adds the main thread) one per remote object however many requests are waiting for it
Count what the name says. Green blocks are worker threads; amber is the I/O thread. Change the scenario and only one column moves: more requests grow thread-per-request, more clients grow thread-per-connection, more objects grow thread-per-object, and the pool never changes.
Worker poolPer-requestPer-connectionPer-object
Threads createdOnce (N fixed)Per requestPer connectionPer object
QueueShared (one per priority)NoneNone: each connection's own streamOne per object
Synchronisation neededShared queue + shared dataShared dataShared dataPer-object queues
OverheadLow; queue contentionHighest (create/destroy)LowLow
Main weaknessFixed size; switchingUnbounded threadsBusy connection waits while others idleHot objects
Good forMany short requests; bounding resources; prioritiesBursty, longer independent requestsSession-based TCP clients sending many requestsObjects with heavy serialised state
How this is examined · PAST EXAM 2016 Q6(a), 4 marks

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?
  1. Thread-per-request
  2. Worker pool
  3. Thread-per-connection
  4. 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
    }
}
  1. new ServerSocket(4444) listens on TCP port 4444. The try (…) form ("try-with-resources") closes it automatically at the end.
  2. listener.accept() blocks until a client connects, then returns a Socket for that one connection.
  3. The main thread wraps the socket in a ConnectionHandler (a Runnable) and starts a new thread for it, then loops straight back to accept() for the next client. This is the "per connection" moment.
  4. In the handler's run(), the streams are wrapped for text: BufferedReader reads lines, PrintWriter with true flushes after each println. readLine() returns each request in order, and returns null when the client closes the connection.
  5. 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.
  6. When the loop ends, try-with-resources closes the socket and streams, run() returns, and the thread dies.
main: while (true) accept() accept() → client 1 accept() → client 2 handler thread 1 (client 1) handler thread 2 (client 2) client 1 closes: run() returns, thread ends each dot is one request: readLine(), process, println(reply), in order on that connection
The main thread never serves anyone; it only accepts. Each connection gets its own coloured thread, which lives exactly as long as the connection.

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();
C9 executor's queue waits FIXED POOL OF 8 THREADS: EVERY THREAD BUSY C1 C2 C3 C4 C5 C6 C7 C8 each thread runs one ConnectionHandler until its client disconnects the 9th client is not served at all until one of C1 to C8 closes its connection
Pitfall: a pool of connection handlers caps the number of clients. Because each handler holds its thread for the whole connection, a fixed pool of 8 serves at most 8 clients at once.
Pitfall

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).

In short
  • 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(); ExecutorService gives a pool, but a pool of connection handlers caps clients.
4.9

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.

CLIENT PROCESS Thread 1generates results buffers Thread 2makes invocations Thread 1 blocks only when the buffers are full SERVER server invoke reply Thread 2 blocks here
Split the waiting from the working. Only Thread 2 ever waits for the network, so Thread 1 keeps computing.

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:

  1. They are cheaper to create and manage (section 4.2).
  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.

In short
  • 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.
4.10

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.

Kernel-level threads

The kernel provides thread system calls and schedules each thread itself. Windows, Linux, Solaris, Mach and Mac OS X work this way.

User-level threads

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.

PROCESS T1 T2 T3 thread library (user space) schedules T1 to T3; must use non-blocking I/O KERNEL sees 1 schedulable entity a blocking call or page fault blocks the whole process
User-level: the kernel sees one process. Cheap and flexible, but one blocked thread stalls them all, and they can never run on two CPUs at once.
PROCESS the kernel knows every thread T1 T2 T3 ready KERNEL SCHEDULER CPU 0 CPU 1 one blocked thread blocks only itself
Kernel-level: each thread is scheduled on its own. Threads of one process can run on several CPUs, but every create and switch needs a system call.

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 threadsKernel-level threads
Switch / createCheap: no trap to the kernelSystem call needed
Scheduling policyCustomisable per application (e.g. real-time multimedia)Fixed kernel policy
Number of threadsMany moreLimited
MultiprocessorCannot use several CPUs for one processCan
Blocking call / page faultBlocks the whole processBlocks only that thread
Priorities across processesNo single global schemeOne 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.

PROCESS: USER THREADS a user-level scheduler maps user threads onto LWPs user thread 1 user thread 2 user thread 3 user thread 4 user thread 5 KERNEL LWP 1: blocked LWP 2: running on a CPU user threads 1 to 3 are all stuck user threads 4 and 5 carry on
Many user threads on a few kernel threads. Switching among user threads on one LWP is cheap and two LWPs can use two CPUs, but the kernel still blocks a whole LWP, not one user thread.

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:

  1. Virtual processor allocated: the user-level scheduler runs a READY thread on it.
  2. SA blocked: the scheduler marks that thread BLOCKED and runs another thread on the notifying SA.
  3. SA unblocked: the scheduler returns the thread to READY.
  4. SA preempted: the scheduler puts the preempted thread back to READY and re-evaluates.
Scheduler activations, event by event
PROCESS A · USER SPACE user-level scheduler assigns READY threads to virtual processors T1 T2 T3 RUNNING on VP1 BLOCKED (waiting for I/O) READY READY RUNNING on VP2 READY (its VP was taken away) READY RUNNING on VP1 KERNEL allocates virtual processors to processes VP1 running T1 running T3 VP2running T2 VP2preempted by the kernel "VP needed" upcall: VP allocated upcall: SA blocked upcall: SA unblocked upcall: SA preempted
  1. The kernel has given process A one virtual processor, VP1. The user-level scheduler runs T1 on it; T2 and T3 are READY.
  2. 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.
  3. 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.
  4. 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.
  5. 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?
  1. Only that thread blocks; the others keep running on the other cores.
  2. The whole process blocks, and it was only ever using one core anyway.
  3. The thread library moves the blocked thread to a kernel thread.
  4. 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.

In short
  • 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.
4.11

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:

  1. Users have an investment in their applications, and emulating other OSs is slow and hard to maintain.
  2. 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:

New start() Runnable scheduled Running run() exits Terminated Non-Runnable (Blocked) sleep, block on I/O, wait for lock, suspend, wait sleep done, I/O complete, lock available, resume, notify
Five states. Non-Runnable merges the lecture's Sleeping, Waiting and Blocked. Leaving it goes back to Runnable, not straight to Running.

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

#ScenarioAnswer
1Two 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.
2withdraw() 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.
3Bursts of thousands of short requests with limited memory and CPU.Worker pool. Its fixed number of threads prevents resource exhaustion.
4A thread's run() finishes normally.Terminated.
5increment() 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.
Exam technique for this Part
  • 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 a while loop, and lock in a global order.
In short
  • 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; synchronized methods 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.