Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·25 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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 with block 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 lambda is a function without a name, and try / except catches 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.
You do not need to know in advance
  • 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:

PYTHON
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:

PYTHON
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

measured observationbench/parallel/cost.py, sections 1 and 2: four tasks of 2,000,000 iterations of a 'sum of squares' loop or of time.sleep(0.2) each; medians of five rounds; CPython 3.13.7 with the GIL, linux/x86_64, os.cpu_count() = 2; 3.14.7 is in the record cost-3.14.txt. This is an observation of one configuration: on 3.14.7 the ratios are different — 0.8 and 1.4. The mechanism (the GIL) explains why threads did not speed up computation; how much processes give you has to be measured.

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.sleep releases the GIL, and four waits proceed at once. Here sleep is 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

language contractThe output is a run of bench/parallel/basics.py, sections 1 and 2. Section 1 is identical on CPython 3.11–3.14; the pickle error text differs by version — see below.

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

measured observationThe time is bench/parallel/cost.py, section 3, CPython 3.13.7 and 3.14.7, linux/x86_64, medians of five rounds. The start method and the full file squares.py are bench/parallel/basics.py, sections 3 and 5, CPython 3.11–3.14, and Lib/multiprocessing/context.py.

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 __main__ module must be importable by worker subprocesses. This means that ProcessPoolExecutor will not work in the interactive interpreter. And the pool at module level with spawn 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

language contractThe output is a run of bench/parallel/basics.py, section 4, identical on CPython 3.11–3.14, regular builds with the GIL. The race is caused by yielding between the read and the write — time.sleep(0); the 'plain += 1' line is for comparison, without yielding: no losses in it is an observation, not a guarantee.

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

Next they ask

How do threads differ from asyncio?

Short answer

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.

Next they ask

How do you return a result from a process?

Short answer

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.

Next they ask

What is the build without the GIL?

Short answer

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

Claim

Threads in Python are useless because of the GIL

Actually

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.

Claim

Since there is a GIL, locks are not needed in threads

Actually

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.

Claim

A process can change the parent's variable

Actually

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.

Claim

Processes are always faster than threads

Actually

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

VersionChangeWhat it means for code
3.11The reference point of the lesson. The error when passing a lambda to a process pool is AttributeError: Can't pickle local object.
3.12fork with live threads gives a DeprecationWarning: the child process may hang.
3.13A 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.14The 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.15Released 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

The function work appends the square of a number to the global list results. It is run in a thread, then in a process. What will this code print? This is a fragment: the imports and the __name__ check are omitted; the full file is bench/parallel/practice.py.
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

Four calls of time.sleep(0.2) were given to a pool of four threads. How many seconds will it all take?
s

Check yourself

Question 1 of 4

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.

Sources & further reading

6 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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 as state["n"] += 1 in 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