Multithreading Concurrency

Multithreading & Concurrency Basics

Programs, processes and threads; what a CPU core runs, what hyperthreading adds, and how context switching shares a core; concurrency versus parallelism, including why CPU-bound Python threads do not speed up. Then the Java API: Thread and Runnable, start() versus run(), join, what an uncaught exception and a daemon thread do, Callable and Future for results, and the six thread states. Every example runs in Java and Python, with verified output.

Suggest an edit

Multithreading & Concurrency Basics — Processes, Threads and Your First Threads

An order is placed. The app must send an SMS, send an email and work out a delivery estimate. Each call waits on a slow service. If the app makes the calls one after another, their waiting times add up. If it makes them at the same time, the whole job takes about as long as the slowest call. That difference is why threads exist.

This lesson builds the vocabulary first: programs, processes, threads, cores, and the difference between concurrency and parallelism. Then it uses Java's thread API to run the three notifications side by side, get a result back, and read what each thread is doing.

💡 The core idea.

  • A process is a running program with its own memory. A thread is a path of execution inside a process; the threads of one process share its memory.
  • Concurrency is making progress on several tasks over the same period. Parallelism is running them at the same instant, which needs several cores.
  • In Java, a Thread runs a Runnable; start() launches it and join() waits for it. A Callable returns a result through a Future.

This is the first lesson of the concurrency chapter. The chapter is about design. The Java language details (thread states, the memory model, the full thread API) are taught in the Java guide's Concurrency: the Basics, and this chapter links there instead of repeating them. Thread Pools & Executors comes next, then thread safety, locks and semaphores, deadlock and producer-consumer. Every output below was produced by running the code on Java 21 and Python 3.11, on a 4-core machine. Thread scheduling varies, so some outputs are labeled illustrative; each shows one real captured run.

You'll be able to: say what a process and a thread own, and why threads in the same process can share data without copying it; tell concurrency from parallelism, and predict when threads speed up CPU-bound work in Java but not in CPython; start threads, wait for them, and spot run() called instead of start(); predict what an uncaught exception in a thread and a daemon thread do to the rest of the program; get a result and an error back from a thread with Callable and Future; read a thread dump's states when a system hangs.

📘 How to read the Intuition boxes. Each one is built in three moves:

  1. The mechanism — what the operating system, the JVM or the Python interpreter do.
  2. A concrete bite — a specific, runnable program where the mechanism produces a surprise.
  3. The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.

Table of contents


1. Program, process, thread

Three words that are easy to blur:

  • A program is instructions on disk: chrome.exe, a .jar, a .py file. It does nothing until it runs.
  • A process is a running copy of a program. The operating system (OS) gives each process its own private memory, its own open files, and at least one thread. The same program can be running as several processes at once: if you start the same script in two terminals, you get two separate processes that don't share memory.
  • A thread is one path of execution inside a process. It has its own call stack and its own position in the code. The OS schedules threads, not processes, onto the CPU. All the threads in a process share its memory (the heap, where objects live) and its open files.

A bakery shows the same three ideas. The recipe book is the program: instructions that do nothing on their own. Baking one cake from it is a process. The bakers working on that cake at the same time, one mixing while another heats the oven, are its threads, and they share one kitchen.

Program(on disk)Process(one running copy)Shared heapobjects, static fieldsThread mainown stackThread worker-1own stack reads/writesreads/writesrun

A program can check this for itself. The code below prints its process id (pid) from two different threads. The pid is the same; only the thread name differs.

Output (illustrative — the pid and core count vary per run and machine; this is one real Java run):

logical CPUs (availableProcessors): 18
physical cores: 14

Analysis. Both lines report the same pid, so main and worker-1 live in one process. Python prints the same shape, with MainThread as the first thread's name. Because they are in one process, both threads see the same objects: a list built by one is readable by the other with no copying.

Intuition. Mechanism. The JVM's heap "is shared among all Java Virtual Machine threads", while each thread gets its own stack [1]. A Java Thread built with new Thread(…) is a platform thread, normally one OS thread underneath [2].

Concrete bite. Shared memory is both useful and risky. Passing data between threads costs nothing, but two threads changing the same data at the same time can corrupt it: Thread Safety & Synchronization shows a counter losing most of its updates.

💡 Earned rule. Picture each thread with its own stack, sharing one heap with every other thread in the process. Any object that two threads can reach needs a plan: which thread may change it, and when.

The cost is that you can no longer understand one thread by reading its code alone. The benefit is cheap communication between threads, which is why most concurrent servers run many threads inside one process.


2. Cores, hyperthreading and context switching

A core is a physical unit that executes instructions. One core runs one thread's instructions at a time, so a 4-core CPU can run 4 threads at the same instant.

A program can ask how many cores it has: Runtime.getRuntime().availableProcessors() in Java, or os.cpu_count() in Python. Both return the number of cores that the operating system reports, which includes logical cores (explained under hyperthreading, below). The machine used for this lesson reports 4; that is the cores available: 4 line printed by the first program in section 1.

Hyperthreading is Intel's name for simultaneous multithreading (SMT). One physical core keeps two sets of thread state (registers, program counter), so the OS sees two logical cores. Both threads feed instructions into the core's one set of execution units. When one thread stalls, for example waiting on memory, the other keeps the units busy.

One physical core with SMTLogical core Aregisters, program counterLogical core Bregisters, program counterShared: execution units, caches

⚠️ Watch out. Two logical cores are not two physical cores. They share one core's execution units and caches, so the gain depends on the workload and is usually well below 2×. Core counts reported by the OS include logical cores.

There are almost always more threads than cores. The OS scheduler shares the cores by context switching:

  1. A thread runs
    • Until its time slice ends, it blocks (on I/O, a lock or sleep), OR
    • A Higher Priority thread needs the core.
  2. The OS saves the thread's state: registers, program counter, stack pointer.
  3. It loads another thread's saved state.
  4. That thread resumes exactly where it stopped.

A switch is not free. Beyond saving and loading registers, the new thread finds the caches full of the old thread's data. With far more busy threads than cores, a CPU spends a growing share of its time switching instead of working.

💡 Earned rule. For work that keeps a core busy, having more runnable threads than cores gains nothing. For work that mostly waits (on the network or the disk), more threads than cores is useful, because a waiting thread does not need a core.

The cost of too many threads is memory for their stacks and time lost to switching. Thread Pools & Executors turns this into a sizing rule.


3. Concurrency vs parallelism

  • Concurrency is about structure: several tasks are in progress over the same period. On one core, the scheduler interleaves them.
  • Parallelism is about execution: several tasks run at the same instant, on different cores.

A concurrent program runs in parallel when there are enough cores. A single core can still run a concurrent program, by switching between its tasks.

Concurrency already pays off when tasks wait. Here are the three order notifications, run one after another:

Output:

SMS sent
Email sent
ETA sent: 25 minutes
total ~1000 ms

The waits add up: 200 + 300 + 500 = 1,000 ms. Section 4 runs the same three calls at the same time and finishes in about 500 ms, the length of the slowest one. That speed-up needs no extra cores. A sleeping thread uses no CPU, so even a single core could overlap the three waits.

CPU-bound work, which keeps the processor busy instead of waiting, is different. It only gets faster if threads run in parallel. This program counts prime numbers twice: once on one thread, and once with the range split across four threads:

Output (Java) (illustrative — timings vary per run; this is one real run on 4 cores):

primes: 283146 sequential, 283146 with 4 threads
1 thread:  925 ms
4 threads: 327 ms

Output (Python) (illustrative — this is one real run on the same machine, with a smaller range because CPython is slower):

primes: 17984 sequential, 17984 with 4 threads
1 thread:  267 ms
4 threads: 313 ms

Analysis. Both languages find the same primes either way. Java's four threads took about a third of the time of one, because they ran on four cores at once. Python's four threads were no faster than one; this run was slightly slower.

Intuition. Mechanism. In CPython, a lock called the Global Interpreter Lock (GIL) lets only one thread execute Python bytecode at a time [3]. Threads still interleave, and a thread that sleeps or waits on I/O releases the GIL, so I/O-bound work overlaps well. CPU-bound Python code gets concurrency without parallelism.

Concrete bite. The Python timings above show it. Splitting pure-Python computation across threads adds the cost of switching between them, but no extra cores to run on. For CPU parallelism, Python uses separate processes (multiprocessing, or ProcessPoolExecutor), each with its own interpreter and GIL.

📘 Python's threading model in this chapter. The Python examples mirror the Java designs so you can compare the coordination tools. They show the same correctness behaviour. They are not a claim that the performance matches: CPU-bound Python threads do not run in parallel.

💡 Earned rule. Ask what the threads will spend their time doing. If they mostly wait for I/O, threads help in both languages, even on one core. If they mostly compute, threads help in Java up to the number of cores; in CPython, use processes instead.

Getting this wrong gives you threads that add complexity but no speed.


4. Creating threads: Thread, Runnable, start and join

A Thread runs a task: the code in a run() method. There are three ways to supply it, and they behave the same:

Output:

SMS sent on sms-thread
Email sent on email-thread
ETA sent on eta-thread

Subclassing Thread ties the task to one way of running it: the code can only ever run as that thread. A Runnable describes only the task, so the same code can later be handed to a thread pool instead. A lambda works as a Runnable because Runnable has a single method. Prefer the Runnable styles.

Now the notifications, side by side. start() launches each thread and returns at once; join() waits for a thread to finish:

Output:

all three started
SMS sent
Email sent
ETA sent: 25 minutes
total ~500 ms

Analysis. all three started printed first: start() did not wait for any task. The three tasks then slept at the same time, so they finished in order of their delays, and the total was about 500 ms instead of 1,000. The three join() calls made main wait for all of them before printing the total.

Intuition. Mechanism. start() asks the JVM to create a new thread, and that thread calls run(). The scheduler decides when each thread runs. The order between threads is not guaranteed unless you coordinate them. Here, the different sleep lengths fixed the order of the three lines, and join() made main print its total last.

Concrete bite. run() and start() are different methods. Calling t.run() is an ordinary method call on the current thread, and no new thread is created. The program still produces the right output, just one task after another, which is why this mistake often survives code review. The Java guide runs this mistake, and also shows what happens when a thread is started twice.

Python has one extra trap. Thread.run() throws away the thread's target after running it, so calling start() on the same object afterwards fails. Create a new Thread instead.

💡 Earned rule. Write the task as a Runnable (usually a lambda), call start() exactly once per thread, and join() every thread whose work you depend on.

The cost is that you now own the coordination: what runs when, and who waits for whom. In real code you will rarely create threads by hand; executors do it for you.


5. When a thread fails, and daemon threads

Starting a thread and never checking on it is called fire-and-forget. Two behaviours decide whether that is safe: what happens when the thread throws, and whether the program waits for it.

Output (Java):

Exception in thread "sms-thread" java.lang.IllegalStateException: SMS gateway down
	at Main.lambda$main$0(Main.java:4)
	at java.base/java.lang.Thread.run(Thread.java:1583)
main is still running: order #42 confirmed

Output (Python) (the hook above shortens the default traceback to one line):

Exception in thread sms-thread: RuntimeError: SMS gateway down
main is still running: order #42 confirmed

Analysis. The SMS thread died with an exception. The JVM printed its stack trace, and main carried on and confirmed the order. Nothing told main that the SMS never went out. In Python the default hook prints a traceback for the failed thread, and the main thread also continues.

Intuition. Mechanism. An uncaught exception ends only the thread it was thrown in. The JVM passes it to the thread's uncaught exception handler, which by default prints it to standard error [2]. Other threads, and the process, keep running.

Concrete bite. This contradicts a common claim, that "a crash in one thread brings down the others". An exception stays inside its thread. That is exactly the danger: the failure is easy to miss. What does stop every thread is a failure of the whole process, such as a call to System.exit, running out of memory, or a crash in native code.

The second behaviour is daemon threads. The JVM exits when every non-daemon thread has finished [2]. A daemon thread does not keep the program alive:

Output:

main done

main finished after 100 ms, and the program exited without waiting for the audit task, which needed 500 ms. The audit line never printed. Python's daemon=True behaves the same [3]. A normal (non-daemon) thread would have kept the program running until the audit log was written.

💡 Earned rule. Use fire-and-forget only for work whose failure you can afford to miss. For anything else, keep a handle to the task (a Future, explained in the next section) and check it, or catch and log errors inside the task. Make a thread a daemon only if it is acceptable to lose its work when the program exits.

Checking costs a few extra lines of code. Not checking costs silent data loss: an SMS that was never sent, an audit line that was never written.


6. Getting a result back: Callable and Future

Runnable.run() returns void and cannot throw checked exceptions. A Callable<V> is the task that returns a V and may throw [4]. You hand it to an ExecutorService, which gives back a Future<V>: a handle to a result that may not exist yet [5].

Output (Java):

submitted; isDone = false
ETA: 25 minutes
isDone = true
get() threw ExecutionException
cause: java.lang.IllegalStateException: routing service down

Output (Python):

submitted; done = False
ETA: 25 minutes
done = True
result() raised RuntimeError
message: routing service down

Analysis. submit() returned at once, before the task was done. get() then made main wait until the ETA was ready, and returned "25 minutes". The broken task's exception did not disappear, the way the SMS thread's exception did in section 5. The Future stored it, and get() threw it to the caller.

Intuition. Mechanism. A Future holds either a value or the exception the task threw. Java's get() wraps the exception in an ExecutionException; the original is its getCause() [6]. Python's result() re-raises the original exception directly [7].

Concrete bite. get() waits for the task to finish. Calling it straight after each submit() turns concurrent code back into sequential code. Submit all the tasks first, do any other work, and call get() only when you need the value.

You can also run a Callable on a plain Thread by wrapping it in a FutureTask, which is both a Runnable and a Future [8]. In practice an executor does that wrapping for you. Python has no FutureTask: executor.submit() already returns a Future for any callable.

💡 Earned rule. When the caller needs a result or needs to know about a failure, use a Callable and keep its Future. Call get() late, and handle ExecutionException (its cause is the real error).

The cost is that get() waits, and without a timeout it can wait forever. get(timeout, unit) sets a limit on the wait.


7. Thread states, briefly

A Java thread is always in one of six states, the constants of Thread.State [9]: NEW, RUNNABLE, BLOCKED (waiting for a monitor another thread holds), WAITING (no time limit, as in join()), TIMED_WAITING (as in sleep(ms)) and TERMINATED. There is no separate RUNNING state: RUNNABLE covers both running on a core and ready to run.

In design work, the states matter most when a system hangs. A thread dump (jstack <pid> or jcmd <pid> Thread.print) lists every thread's state: BLOCKED threads wait for a lock, WAITING threads wait for another thread to act. The Java guide's Concurrency: the Basics, section 2 puts a thread into each state and draws the transitions. Python's threading has no state enum; is_alive() only tells you whether a thread has started and not yet finished.


8. Threads or processes?

Process Thread
Memory its own address space, isolated shares its process's heap
Talking to others inter-process communication: pipes, sockets, shared files read and write the same objects
Cost to create and switch higher lower
A bug that corrupts memory stays in that process can corrupt data every thread uses
A crash of the whole process other processes survive every thread dies with it

Two real systems, each choosing deliberately:

  • Chrome runs tabs in separate renderer processes, not threads, so a misbehaving page can crash its own tab without taking down the browser, and a sandboxed renderer cannot read another site's memory [10].
  • PostgreSQL serves each client connection with its own server process [11]. Most Java web servers serve each request on a thread from a pool.

Choose threads when tasks share data, communicate often, and are part of one program's logic: handling requests in a server, keeping a UI responsive while work runs in the background. Choose processes when you need isolation:

  • Fault isolation: one task crashing or leaking memory must not take the others down.
  • Security boundaries: untrusted code (a web page, a plugin) must not read the rest of memory.
  • Resource limits: the OS can cap a process's memory and CPU.
  • Different runtimes: a Python worker beside a Java service.
  • CPU parallelism in CPython, because of the GIL (see section 3, Concurrency vs parallelism).

💡 Earned rule. Default to threads inside one process for work that shares data. Reach for separate processes when a failure, a memory leak or a security breach in one task must not reach the others.

The cost of processes is slower communication, because data must be copied between them. The cost of threads is that they are not protected from each other: because they share memory, a bug in one thread can corrupt data that all of them use.


9. Mental-model summary

Principle Consequence
A process owns its memory; its threads share the heap, and each thread has its own stack Passing data between threads is free; changing shared data needs coordination
One core runs one thread at a time; SMT shares one core between two logical cores Core counts include logical cores; SMT gains are well below 2×
The scheduler shares cores by context switching With many more busy threads than cores, time is wasted on switching
Concurrency is overlapping tasks; parallelism is running them at the same instant Waiting tasks overlap even on one core; CPU-bound tasks need cores
CPython's GIL runs one thread's bytecode at a time CPU-bound Python threads don't speed up; use processes
start() creates a thread; run() is a plain call; join() waits run() instead of start() silently runs sequentially
An uncaught exception ends only its own thread main carries on, unaware; check results or catch inside the task
The JVM exits when all non-daemon threads finish A daemon thread's unfinished work is dropped at exit
Callable + Future return a value or the task's exception Java wraps it in ExecutionException; Python re-raises it
Six states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED There is no RUNNING state; a thread dump shows what each thread waits for

10. Gotcha checklist

Symptom Likely cause Fix
The task ran on main; the thread is still NEW run() was called instead of start() call start()
IllegalThreadStateException from start() the same Thread was started twice create a new Thread, or use an executor
Python: AttributeError: … '_target' after run() then start() run() used up the thread's target create a fresh Thread
Threads made CPU-bound Python code no faster the GIL runs one thread's bytecode at a time use ProcessPoolExecutor or multiprocessing
A stack trace appears, but the program carries on as if all is well an uncaught exception ended only its own thread catch inside the task, or keep and check a Future
Work in a background thread is silently missing at exit it was a daemon thread make it non-daemon, or join() it before exit
get() throws ExecutionException the task threw handle e.getCause(), the real error
A "concurrent" program takes as long as the sequential one get() or join() right after each start()/submit() start everything first, wait last
A thread dump shows threads BLOCKED for a long time they wait for a lock another thread holds find the holder; see Deadlock

✅ Check yourself

One check per objective. Answer before you open anything.

The 🧪 box below: sequential vs threaded totals, the effect of one join() moved, and a daemon thread's last line.
  1. One after another: 400 + 400 + 400 = about 1,200 ms. With three threads started together and joined at the end: about 400 ms, because the waits overlap.
  2. If each join() comes straight after its start(), each thread finishes before the next starts. The total goes back to about 1,000 ms: the code uses threads, but the tasks still run one after another.
  3. The line prints only if the daemon finishes before main does. With main ending after 100 ms and the daemon sleeping 500 ms, it never prints. Make the thread non-daemon, or join() it, and it does.

📚 Sources

  1. The Java Virtual Machine Specification, Java SE 21, §2.5.2 "Java Virtual Machine Stacks" and §2.5.3 "Heap" — https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.5
  2. java.lang.Thread, Java SE 21 API (platform threads, daemon threads, start(), join(), interrupt(), UncaughtExceptionHandler) — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html
  3. Python 3 documentation, threading — Thread-based parallelism (the GIL note; daemon threads) — https://docs.python.org/3/library/threading.html
  4. java.util.concurrent.Callable, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Callable.html
  5. java.util.concurrent.Future, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Future.html
  6. java.util.concurrent.ExecutionException, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ExecutionException.html
  7. Python 3 documentation, concurrent.futures — Future objects — https://docs.python.org/3/library/concurrent.futures.html#future-objects
  8. java.util.concurrent.FutureTask, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/FutureTask.html
  9. java.lang.Thread.State, Java SE 21 API — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.State.html
  10. The Chromium Projects, "Multi-process Architecture" — https://www.chromium.org/developers/design-documents/multi-process-architecture/
  11. PostgreSQL documentation, "How Connections Are Established" — https://www.postgresql.org/docs/current/connect-estab.html

🧪 Predict, then check.

  1. Change all three delays in the notification programs from sections 3 and 4 to 400 ms. Predict the sequential and the threaded totals, then run both.
  2. In the threaded version from section 4, move each join() to directly after its start(). Predict the total.
  3. In the daemon program from section 5, predict whether audit log written prints. Then remove setDaemon(true) (or daemon=True) and predict again.

Your Turn

Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.

Mark as readBook versionff97b7f