Threads and processes: what to choose for computation, and what for waiting
"Threads or processes?" is a question answered with a rule and checked by understanding the reason. A run on CPython 3.13.7 (the regular build with the GIL, Linux, 2 cores) answers: four computational tasks in four threads took 0.478 s versus 0.502 sequentially, in four processes 0.262; four waits of 0.2 s in threads took 0.201 s; a thread writes to the shared list, while a process writes to its own copy; a lambda cannot be passed to a process pool; a process pool is created in main() and run as a file; and on 3.14 on Linux processes start with forkserver rather than fork by default.
Full technical treatment
TL;DR
A thread is another line of execution in the same process, with the same memory; a process is a separate program with its own memory and its own interpreter. In the regular CPython build — the build with the GIL — the threads of one process run Python code in turn: they are separated by the global interpreter lock, the GIL. Each process has its own GIL.
Hence the main rule for the build with the GIL: threads for waiting, processes for computation in Python. While a thread waits for the network or the disk, the GIL is free: in the run four waits of 0.2 s in four threads took 0.201 s. While a thread runs Python code, it holds the GIL: four computational tasks in four threads took 0.478 s versus 0.502 sequentially, and in four processes 0.262 s. That is CPython 3.13.7 on a machine with two cores; on 3.14.7 the ratios came out different, and on your machine they have to be measured. The exception to the rule is C extensions: some release the GIL during heavy computation, for example compression or hashing.
Beyond that comes what separates understanding from having read about it.
Processes do not share memory: a thread appended to the shared list, a
process to its own copy, and the parent did not see it. Tasks are passed to a
process via pickle — serialization of an object into bytes — and a lambda
cannot be passed that way. A process pool is created inside main() under
if __name__ == "__main__": and run as a file. Starting a process pool cost
milliseconds in the run, a thread pool fractions of a millisecond. The GIL
does not make read-modify-write atomic: when a thread yielded between the read
and the write, updates to a shared counter were lost. On 3.14 the default
start method on Linux is forkserver instead of fork, the free-threaded build
is officially supported, and concurrent.futures gained
InterpreterPoolExecutor.
- what a function and a global variable are;
- that a program can wait for the network or the disk;
- why a script has the check
if __name__ == "__main__":— the lesson "Modules and imports"; - that a
withblock closes at the end what it opened — here it shuts the pool down; in detail, the lesson on context managers, which comes later in the track; - that a
lambdais a function without a name, andtry/exceptcatches an exception; in detail, the lessons on lambda and on exceptions, which come later in the track; - what the GIL is — at least in one sentence, given in the summary above; in detail, the article on the GIL.
threading,multiprocessing,concurrent.futures;- computation versus waiting, the cost of starting a pool, pickle;
- races and
Lock, start methods, the changes in 3.13 and 3.14.
What it was checked on: CPython 3.11.15, 3.12.3, 3.13.7 and 3.14.7 — regular
builds with the GIL, linux/x86_64, os.cpu_count() = 2; time was measured on
3.13.7 and 3.14.7. The documentation was checked on 14.10.2026. By that date
Python 3.15 had also been released (9 October 2026) — the lesson was not run on
it; nor did the lesson measure the free-threaded build.
Base: a thread and a process
A fragment — what the two pools look like; fetch, urls, compute and
chunks are not defined in it:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(4) as pool: # four threads in this process
results = list(pool.map(fetch, urls))
with ProcessPoolExecutor(4) as pool: # four separate processes
results = list(pool.map(compute, chunks))The full file squares.py — the same process pool in a form you can copy
into an empty folder and run with python3 squares.py. Run it as a file — not
in the interactive interpreter (REPL) and not via python -c:
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import get_context
def compute(n):
return sum(i * i for i in range(n))
def main():
chunks = [10, 100, 1000, 10000]
with ProcessPoolExecutor(4, mp_context=get_context("spawn")) as pool:
print(list(pool.map(compute, chunks)))
if __name__ == "__main__":
main()The three rules of this template: the function the processes run is defined
at module level; the data and the pool are created inside main(); main()
is called under if __name__ == "__main__":. The spawn start method is set
explicitly: that way the child process starts as a new interpreter and imports
this file again — as on Windows and macOS, where spawn is the default.
Importing the file does not create a pool. How this template ran on four
versions, and what happens without the __name__ check, is in Mechanism 3.
A junior-level answer to "how does a thread differ from a process": threads run in one process and share memory, processes are isolated; in the regular CPython build, because of the GIL, threads do not speed up computation in Python, so they are used for I/O, and processes for computation. That is correct, and it is followed by questions: how little threads actually help, what does "share memory" mean in practice, what can be passed to a process, and why locks if there is a GIL. The mechanisms below answer them with a run.
Mechanism 1: computation and waiting
Four identical tasks — sequentially, in a pool of four threads and in a pool
of four processes. Output of bench/parallel/cost.py, sections 1 and 2;
labels translated:
1) computation: 4 "sum of squares" tasks, seconds, medians of 5 rounds
sequentially 0.502 speedup 1.0
4 threads 0.478 speedup 1.1
4 processes 0.262 speedup 1.9
2) waiting: 4 time.sleep(0.2) tasks, seconds, medians of 5 rounds
sequentially 0.801 speedup 1.0
4 threads 0.201 speedup 4.0
4 processes 0.212 speedup 3.8
- Computation. Threads hardly helped: while a thread runs Python code it
holds the GIL, and the others wait their turn. Processes computed in
parallel — each has its own interpreter and its own GIL: a speedup of 1.9
on a machine where
os.cpu_count()is 2. On 3.14.7 the same task in threads was even slower than sequential (0.8), and processes gave 1.4. These ratios are from one run on one machine: they depend on the version, the number of cores and the cost of starting processes. - Waiting. Threads gave a fourfold speedup:
time.sleepreleases the GIL, and four waits proceed at once. Heresleepis a model of waiting: the GIL is released during I/O as well, but this measurement does not test a real network or database. Processes gave almost the same, but at a higher cost — see Mechanism 3.
The rule "computation goes to processes" applies to Python code in the build
with the GIL. C extensions can release the GIL themselves — in the Python
glossary:
some extension modules, either standard or third-party, are designed so as to release the GIL when doing computationally intensive tasks such as compression or hashing
.
Threads can speed up such computation; the lesson did not measure it.
How exactly the GIL switches between threads and why it is held is covered in the article on the GIL.
Mechanism 2: shared memory and pickle
Output of bench/parallel/basics.py, sections 1 and 2, CPython 3.13.7; labels
translated, error texts verbatim:
1) memory: a thread versus a process
after the thread shared = [9]
after the process shared = [9] | process exitcode: 0
2) what can be handed to a pool
threads, module function -> 25
threads, lambda -> 25
processes, module function -> 25
processes, lambda -> AttributeError: Can't get local object 'main.<locals>.<lambda>'
The function appends the square of a number to a global list. The thread
appended 9 — and the main program sees it: threads share memory. The process
appended 16 to its own copy of the list and finished successfully, but the
parent still has [9]: a process has its own memory. A result is returned
from a process explicitly — via pool.submit(...).result(), a queue or a
pipe.
To pass a task and its arguments to another process they are serialized with
the pickle module. It passes a module function by name, but not a local
function or a lambda: the lambda suited the thread pool, not the process
pool. The error depends on the version: on 3.11–3.13 it is an
AttributeError (with the text Can't pickle local object on 3.11 and 3.12,
Can't get local object on 3.13), and on 3.14 it is a different type,
PicklingError: Can't pickle local object. Code that caught
except AttributeError will miss this error on 3.14.
Mechanism 3: the cost of processes and the start method
Output of bench/parallel/cost.py, section 3, CPython 3.13.7; labels
translated:
3) starting a pool and one empty task, ms, medians of 5 rounds
thread pool 0.15
process pool 9.09
A thread is created inside the process in a fraction of a millisecond. A process has to be started, and every task and result serialized and passed. That is why processes pay off on large computational tasks and lose on small ones.
How a process is started depends on the system and the version. Output of
bench/parallel/basics.py, section 3; labels translated, error and warning
texts verbatim:
3) environment
sys.platform -> linux
os.cpu_count() -> 2
sys._is_gil_enabled() -> True
multiprocessing.get_start_method() -> fork
python -X gil=0 -> Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build
starting a process without if __name__ == '__main__' -> exitcode 0
fork with a live thread -> DeprecationWarning: This process (pid=…) is multi-threaded, use of fork() may lead to deadlocks in the child.
This is the 3.13 output. fork copies the parent's memory, but of its
threads only the one that called fork; if other threads are running in the
parent, the child process may hang on a lock held by a thread that does not
exist in it. Since 3.12 Python warns about this — the last line; on 3.11
there is no warning.
On 3.14 on Linux the get_start_method() line says forkserver: the default
was changed to one that is safer with threads. spawn starts the child
process as a new interpreter, forkserver forks it from a clean server
process started in advance, and in both cases the main module is imported
again. That is why the code that starts processes is put under
if __name__ == "__main__":. On 3.13 the script without this check ran
(exitcode 0), while on 3.14 the same script failed:
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase.
The squares.py template from the base section was checked by the same
script: the file is written to a temporary folder, run, imported and handed to
the interpreter via python -c; for comparison — the same pool without the
__name__ check, at module level. Output of bench/parallel/basics.py,
section 5, CPython 3.13.7; labels translated, error texts verbatim:
5) the full file squares.py: a process pool, the spawn start method
python squares.py -> [285, 328350, 332833500, 333283335000]
import squares: child processes -> 0 | stderr empty: True
the same code via python -c -> AttributeError: Can't get attribute 'compute' on <module '__main__' (<class '_frozen_importlib.BuiltinImporter'>)>
no if __name__, pool at module level -> RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase.
As a file the template ran on all four versions, and import squares created
no process: the pool appears only when main() is called. Via python -c the
main module has no file: the child process cannot import it and does not find
compute (the error text depends on the version; on 3.14 it is
AttributeError: module '__main__' has no attribute 'compute'). That is the
limitation from the documentation:
The
.
And the pool at module level with __main__ module must be importable by worker subprocesses. This means that ProcessPoolExecutor will not work in the interactive interpreterspawn failed with the same RuntimeError
on all four versions — not only on 3.14, where the default forkserver caused
it.
The process pool on 3.14 started in 18.86 ms in the measurement. It cannot be compared directly with 9.09 on 3.13: with different start methods the pool starts its worker processes differently.
Deeper: the GIL does not protect your code from races
In the regular build the GIL guarantees that two threads do not execute
bytecode at the same time, but not that your "read, modify, write" operation
will run without interruption. Each of four threads reads a shared counter a
thousand times, yields with time.sleep(0) and writes back the value plus
one. Output of bench/parallel/basics.py, section 4; labels translated:
4) a race: 4 threads with 1000 increments of a shared counter each
without a lock total == 4000: False
with a Lock total == 4000: True
plain += 1 total == 4000: True
Without a lock two threads manage to read the same value, and one increment
is lost. With threading.Lock the read and the write run as one piece, and
the total is exact. The last line is a plain += 1 without yielding: in the
recorded runs the total matched 4000. That is an observation, not a
guarantee: no losses in one run does not prove there will be none in another
— on another version, build or load. The language does not promise that such
an operation is atomic: the Python FAQ lists D[x] = D[x] + 1 among the
operations that are not atomic and ends with the advice
When in doubt, use a mutex!
. The rule: shared mutable state between
threads is protected with a lock or not shared at all — data is passed via
queue.Queue.
sys._is_gil_enabled() appeared in 3.13 — together with the possibility of
building CPython without the GIL. In the regular build, on which all the
measurements of the lesson were made, it returns True, and an attempt to
switch the GIL off with the -X gil=0 option ends in the error
Disabling the GIL is not supported by this build — the -X gil=0 line of
the "environment" section above. On 3.11 and 3.12 this option does not exist yet,
and the interpreter simply does not check it.
In 3.14 the build without the GIL has a new status — in What's New 3.14:
PEP 779: Free-threaded Python is officially supported
. It is still a
separate build: the regular 3.14.7 interpreter in the run has the GIL. In the
free-threaded build threads can run Python code in parallel, so the rule
"computation goes to processes" does not directly apply to it; the lesson did
not measure it.
One more option from 3.14 is concurrent.futures.InterpreterPoolExecutor: a
pool of several isolated interpreters in one process. According to the
documentation,
Each interpreter has its own Global Interpreter Lock, which means code run with this executor has true multi-core parallelism
;
the interpreters do not share mutable objects. Output of
bench/parallel/basics.py, section 6, CPython 3.14.7; labels translated:
6) a pool of interpreters in one process
concurrent.futures.InterpreterPoolExecutor -> present, module function -> 25
On 3.11–3.13 this class does not exist. The lesson did not measure its time.
How to answer in an interview
Short answer: threads share the memory of a process, processes are
isolated. In regular CPython — the build with the GIL — threads run Python
code in turn, so for waiting — network, disk — you take threads or asyncio,
and for computation in Python — processes; C extensions can release the GIL
themselves. Tasks are passed to processes via pickle, starting processes is
more expensive, and a process pool is created in main() under
if __name__ == "__main__":. The GIL does not prevent races: shared state is
protected with a Lock.
That is enough for a correct answer. What follows is what you add if the interviewer digs.
If the interviewer digs deeper
On "how little do threads help with computation". "In my measurement on CPython 3.13.7, on a machine with two cores, four tasks in four threads took 0.478 s versus 0.502 sequentially, in processes 0.262. And four waits of 0.2 s in threads took 0.201. On 3.14.7 the ratios were different, so for my own task I would measure."
On "why a Lock, if there is a GIL". "The GIL does not make my
read-modify-write operation atomic. When a thread yields between the read and
the write, four threads with a thousand increments each did not give 4000;
with a Lock they did. A plain += lost nothing in my runs, but that is not a
guarantee: the Python FAQ lists D[x] = D[x] + 1 among the operations that
are not atomic."
On "why doesn't a lambda work in ProcessPoolExecutor". "The task is passed to another process via pickle, and pickle passes a function by a name it can be found by on import. A lambda or a local function has no such name. In a thread pool a lambda works."
On "what changed in recent versions". "In 3.13 a build without the GIL
and sys._is_gil_enabled() appeared; in 3.14 the free-threaded build became
officially supported, but the regular build still has the GIL. Also in 3.14
the default start method on Linux is forkserver instead of fork, and
InterpreterPoolExecutor appeared — a pool of interpreters in one process."
Next they ask
How do threads differ from asyncio?
Threads are switched without your code taking part — between any operations;
in asyncio tasks switch only at await points, in one thread. For thousands of
simultaneous waits asyncio needs no thread per wait. For code that cannot
await — a synchronous database driver, for example — threads remain. More in
the async/await lesson.
How do you return a result from a process?
Not through a global variable — in the run the process appended to its own
copy of the list, and the parent did not see it. Through
ProcessPoolExecutor: submit returns a future, result() the value passed
back by the same pickle. Or through multiprocessing.Queue and Pipe.
What is the build without the GIL?
A separate CPython build, which appeared in 3.13, in which threads can run
Python code in parallel; in 3.14 it became officially supported. It has to be
installed separately: the regular build has the GIL, sys._is_gil_enabled()
in it returns True, and -X gil=0 an error, as in the lesson's run.
Everything the lesson says about threads applies to the regular build.
Common misconceptions
Threads in Python are useless because of the GIL
For waiting they are useful: in the run four waits of 0.2 s in four threads took 0.201 s instead of 0.801. In the build with the GIL computation in Python is not sped up; C extensions can release the GIL during computation too.
Since there is a GIL, locks are not needed in threads
The GIL does not make a read-modify-write operation atomic. When a thread yields between the steps, four threads with a thousand increments each did not give 4000 without a Lock. That a plain += lost nothing in the run is an observation, not a guarantee.
A process can change the parent's variable
A process has its own memory: the process appended to the list, and the parent still has [9]. The result is returned explicitly — via a future, a queue or a pipe.
Processes are always faster than threads
Starting a process pool in the measurement took 9.09 ms versus 0.15 for threads, and every task has to be serialized. On small tasks and on waiting, processes lose, and the speedup on computation depends on the machine: on two cores, 1.9 on 3.13.7 and 1.4 on 3.14.7.
Version history
| Version | Change | What it means for code |
|---|---|---|
| 3.11 | The reference point of the lesson. The error when passing a lambda to a process pool is AttributeError: Can't pickle local object. | |
| 3.12 | fork with live threads gives a DeprecationWarning: the child process may hang. | |
| 3.13 | A build without the GIL and sys._is_gil_enabled() appeared; in the regular build the function returns True, and -X gil=0 an error. The pickle error for a lambda is AttributeError: Can't get local object. | |
| 3.14 | The default start method on Linux is forkserver instead of fork: the main module is imported again, and in the run a script without if __name__ == "__main__" failed with a RuntimeError. The pickle error for a lambda is a different type: PicklingError. The free-threaded build is officially supported (PEP 779) — the lesson did not measure it; concurrent.futures.InterpreterPoolExecutor appeared. | |
| 3.15 | Released on 9 October 2026. The lesson was not run on it: the runs are CPython 3.11–3.14, checked on 14.10.2026. |
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
t = threading.Thread(target=work, args=(3,)) t.start() t.join() p = multiprocessing.Process(target=work, args=(4,)) p.start() p.join() print(results)
Practice · estimate
Check yourself
A service downloads 100 pages over the network. What do you use?
How it was measured
The numbers and observations of this lesson come from these scripts. Each one opens right from here — together with the record of its run: what it ran on, what came out and with what spread.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A thread is another line of execution in the same process, with the same memory; a process is a separate program with its own memory and its own interpreter. In the regular CPython build — the build with the GIL — the threads of one process run Python code in turn: they are separated by the global interpreter lock, the GIL. Each process has its own GIL.
- Hence the main rule for the build with the GIL: threads for waiting, processes for computation in Python. While a thread waits for the network or the disk, the GIL is free: in the run four waits of 0.2 s in four threads took 0.201 s. While a thread runs Python code, it holds the GIL: four computational tasks in four threads took 0.478 s versus 0.502 sequentially, and in four processes 0.262 s. That is CPython 3.13.7 on a machine with two cores; on 3.14.7 the ratios came out different, and on your machine they have to be measured. The exception to the rule is C extensions: some release the GIL during heavy computation, for example compression or hashing.
- Beyond that comes what separates understanding from having read about it. Processes do not share memory: a thread appended to the shared list, a process to its own copy, and the parent did not see it. Tasks are passed to a process via pickle — serialization of an object into bytes — and a lambda cannot be passed that way. A process pool is created inside
main()underif __name__ == "__main__":and run as a file. Starting a process pool cost milliseconds in the run, a thread pool fractions of a millisecond. The GIL does not make read-modify-write atomic: when a thread yielded between the read and the write, updates to a shared counter were lost. On 3.14 the default start method on Linux is forkserver instead of fork, the free-threaded build is officially supported, andconcurrent.futuresgainedInterpreterPoolExecutor.
In fact
- For waiting they are useful: in the run four waits of 0.2 s in four threads took 0.201 s instead of 0.801. In the build with the GIL computation in Python is not sped up; C extensions can release the GIL during computation too.
- The GIL does not make a read-modify-write operation atomic. When a thread yields between the steps, four threads with a thousand increments each did not give 4000 without a
Lock. That a plain+=lost nothing in the run is an observation, not a guarantee. - A process has its own memory: the process appended to the list, and the parent still has
[9]. The result is returned explicitly — via a future, a queue or a pipe. - Starting a process pool in the measurement took 9.09 ms versus 0.15 for threads, and every task has to be serialized. On small tasks and on waiting, processes lose, and the speedup on computation depends on the machine: on two cores, 1.9 on 3.13.7 and 1.4 on 3.14.7.
By version
- 3.11
- The reference point of the lesson. The error when passing a lambda to a process pool is
AttributeError: Can't pickle local object.< - 3.12
forkwith live threads gives aDeprecationWarning: the child process may hang.<- 3.13
- A build without the GIL and
sys._is_gil_enabled()appeared; in the regular build the function returnsTrue, and-X gil=0an error. The pickle error for a lambda isAttributeError: Can't get local object.< - 3.14
- The default start method on Linux is
forkserverinstead offork: the main module is imported again, and in the run a script withoutif __name__ == "__main__"failed with aRuntimeError. The pickle error for a lambda is a different type:PicklingError. The free-threaded build is officially supported (PEP 779) — the lesson did not measure it;concurrent.futures.InterpreterPoolExecutorappeared.< - 3.15
- Released on 9 October 2026. The lesson was not run on it: the runs are CPython 3.11–3.14, checked on 14.10.2026.<
What is covered
- Base: a thread and a process
- Mechanism 1: computation and waiting
- Mechanism 2: shared memory and pickle
- Mechanism 3: the cost of processes and the start method
- Deeper: the GIL does not protect your code from races
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Check yourself
- How it was measured
Sources & further reading
6 SOURCES
- Lib/multiprocessing/context.py — the default start methodCPython source code. At tag v3.13.7, lines 323–328: spawn on macOS, and on other POSIX systems
_default_context = DefaultContext(_concrete_contexts['fork']). At tag v3.14.0, lines 323–328: «gh-84559: We changed everyones default to a thread safeish one in 3.14», and where the system can pass descriptors and it is not macOS —DefaultContext(_concrete_contexts['forkserver']).https://github.com/python/cpython/blob/v3.14.0/Lib/multiprocessing/context.py - sys._is_gil_enabledOfficial documentation. The docstring of the function added in 3.13: «Return True if the GIL is currently enabled and False otherwise». Checked by calling sys._is_gil_enabled.__doc__ on 3.13.7; the function does not exist on 3.11 and 3.12.https://docs.python.org/3.13/library/sys.html#sys._is_gil_enabled
- What's New In Python 3.14 — the free-threaded build, InterpreterPoolExecutor, the start methodOfficial documentation. Among the 3.14 highlights: «PEP 779: Free-threaded Python is officially supported». On the new pool: «Add a new executor class, InterpreterPoolExecutor, which exposes multiple Python interpreters in the same process ('subinterpreters') to Python code. This uses a pool of independent Python interpreters to execute calls asynchronously». On the start method: «This change does not affect Windows or macOS, where 'spawn' remains the default start method». The lesson did not measure the free-threaded build: all runs are regular builds with the GIL. The presence of InterpreterPoolExecutor was checked by a run of
bench/parallel/basics.py, section 6: present on 3.14.7, absent on 3.11–3.13. Checked on 14.10.2026.https://docs.python.org/3/whatsnew/3.14.html - concurrent.futures — the process pool and the interpreter poolOfficial documentation. On ProcessPoolExecutor: «The __main__ module must be importable by worker subprocesses. This means that ProcessPoolExecutor will not work in the interactive interpreter». On InterpreterPoolExecutor, added in 3.14: «Each interpreter has its own Global Interpreter Lock, which means code run with this executor has true multi-core parallelism» and «The worker interpreters are isolated from each other, which means each has its own runtime state and that they can't share any mutable objects or other data». Checked on 14.10.2026.https://docs.python.org/3/library/concurrent.futures.html
- Python glossary: global interpreter lockOfficial documentation. «However, some extension modules, either standard or third-party, are designed so as to release the GIL when doing computationally intensive tasks such as compression or hashing. Also, the GIL is always released when doing I/O». Checked against the 3.14 documentation on 14.10.2026; the lesson did not measure extensions.https://docs.python.org/3.14/glossary.html#term-global-interpreter-lock
- Library and Extension FAQ — what kinds of global value mutation are thread-safeOfficial documentation. The list of operations that are not atomic includes
D[x] = D[x] + 1— the same read-modify-write asstate["n"] += 1in section 4 of the run. The FAQ answer ends with the advice «When in doubt, use a mutex!». Checked on 14.10.2026.https://docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe