Skip to content
← All Python articles

Python explainer

How Python Starts Worker Processes

About 5 min to read. Allow extra time to work through examples.

Learn what spawn, fork and forkserver copy into a worker, why Python 3.14 stopped using fork on Linux, and why forking a program with threads can hang.

This article uses Python. You can make it your practice mode to browse related content across the site.

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.

  • fork copies 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.
  • spawn starts a fresh Python that imports your main module again. The worker imports it under a different name, so the if __name__ == "__main__": block does not run there. It is the slowest to start, and it works everywhere.
  • forkserver starts 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 way spawn does. 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

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. The logging module does this. It cannot fix locks that belong to other code.
  • Contexts and start methods in the multiprocessing documentation lists where each method runs.

step 4 of 24

Parallel Python with Process Pools

Up next Give Each Worker Its Own Database Connection