Your program puts its settings in a global dictionary, then starts a process pool. Each job reads a setting:
from concurrent.futures import ProcessPoolExecutor
SETTINGS: dict[str, str] = {}
def read_setting(key: str) -> str:
return SETTINGS.get(key, "missing")
if __name__ == "__main__":
SETTINGS["region"] = "eu"
with ProcessPoolExecutor(max_workers=1) as pool:
print(pool.submit(read_setting, "region").result())
On Linux with Python 3.13, this prints eu. On Linux with Python 3.14, and
on macOS and Windows, it prints missing. The code is the same. What changed
is how Python starts the worker.
Three ways to start a worker
Python calls these start methods. Each one decides what the new worker copies from your program.
forkcopies your running program, including all of its memory. The worker sees every global and every imported module as they were at that moment. It is the fastest to start, and it does not exist on Windows.spawnstarts a fresh Python that imports your main module again. The worker imports it under a different name, so theif __name__ == "__main__":block does not run there. It is the slowest to start, and it works everywhere.forkserverstarts one helper process, the fork server, the first time you need a worker. The server is a fresh Python with a single thread, and it imports your main module the wayspawndoes. Each worker is a copy of the server, not of your program. It does not exist on Windows.
In the example, SETTINGS["region"] = "eu" runs only in the main block. A
fork worker copies the filled dictionary. A spawn or forkserver worker
imports the module itself and gets an empty one.
The default depends on your platform:
| Platform | Python 3.12 and 3.13 | Python 3.14 and later |
|---|---|---|
| Linux | fork |
forkserver |
| macOS | spawn |
spawn |
| Windows | spawn |
spawn |
multiprocessing.get_start_method() tells you which one you have.
Why Python 3.14 stopped using fork on Linux
fork copies all the memory but only one thread, the one that called it. Suppose another
thread holds a lock at that moment. The worker gets a copy of the lock, still
locked, and the thread that would release it does not exist in the worker.
Code in the worker that waits for that lock waits forever. This is a
deadlock, a wait that can never end.
Run this on Linux or macOS to see it:
import os
import threading
import time
lock = threading.Lock()
def hold_lock() -> None:
with lock:
time.sleep(1)
threading.Thread(target=hold_lock).start()
time.sleep(0.1) # give the thread time to take the lock
pid = os.fork()
if pid == 0:
print("child got the lock:", lock.acquire(timeout=3), flush=True)
os._exit(0)
os.waitpid(pid, 0)
print("parent got the lock:", lock.acquire(timeout=3))
DeprecationWarning: This process (pid=51546) is multi-threaded, use of fork() may lead to deadlocks in the child.
child got the lock: False
parent got the lock: True
The thread releases the lock after one second, but only in the parent. The
child's copy stays locked, and without the timeout the child would hang.
Since Python 3.12, os.fork() warns like this when it detects more than one
thread.
Don't fork a program that has threads
You can have threads without starting any yourself. A ThreadPoolExecutor
and a logging QueueListener both start threads, and some libraries, such as
metrics clients, start their own. If one of them holds a lock at the moment a
fork worker starts, that worker hangs. It happens only sometimes, so it is
hard to reproduce.
The fix has two parts. Use forkserver or spawn, which start each worker
from a fresh process with one thread. Then hand each worker what it needs,
instead of relying on a copied global. An initializer is a function the
pool runs once in each worker when it starts. Add one to the first script:
def load_settings(settings: dict[str, str]) -> None:
SETTINGS.update(settings)
if __name__ == "__main__":
with ProcessPoolExecutor(
initializer=load_settings, initargs=({"region": "eu"},)
) as pool:
print(pool.submit(read_setting, "region").result()) # eu
This prints eu with every start method. To choose one for a single pool,
pass mp_context=multiprocessing.get_context("spawn").
The fork server imports your main module too. If that import starts a
thread, for example by creating a metrics client at the top level,
forkserver has the same risk as fork. Create such objects in the main
block or in an initializer.
Check your understanding
Your script fills a cache in its if __name__ == "__main__": block, then starts a process pool on Linux with Python 3.14. What do the workers see?
Show answer
Hide answer
An empty cache. The default is forkserver, so each worker is a copy of the
fork server. The server imported your module but never ran the main block.
Pass the cache in with initializer and initargs, or send each job the data
it needs as an argument. Switching back to fork would hide the problem and
bring back the deadlock risk.
Going further
os.register_at_fork()lets a library run code before and after a fork, for example to reset its own locks in the child. Theloggingmodule does this. It cannot fix locks that belong to other code.- Contexts and start methods
in the
multiprocessingdocumentation lists where each method runs.