Fork followed by exec is one common Unix process-launch model. fork creates a child process, while a successful execve replaces the calling process’s program image. They solve different problems and have different inheritance rules. Understanding the distinction matters when arranging input, output, environment, and lifecycle management for a command started by another program.

When it breaks

Fork does not make all subsequent state independent. Ordinary private memory diverges between parent and child, commonly through copy-on-write, but shared mappings remain shared. Inherited descriptors also refer to the same open file descriptions as their parent counterparts, so ordinary file offsets can move in both processes when either one reads. Copying the descriptor table does not independently reopen each file.

Exec does not simply preserve every launch setting. Descriptors marked close-on-exec close during successful execution, while other eligible descriptors survive. execve receives an explicit environment array instead of automatically reproducing the caller’s current environment. Caught signal dispositions reset and old memory mappings disappear, even though process identity and several other attributes continue.

Forking a multithreaded process needs particular care. The child contains only the calling thread but can inherit locks whose owning threads are absent. Arbitrary library work in the gap can deadlock. The allowed pre-exec operations are therefore constrained; a convenient single-threaded demonstration must not become an unrestricted recipe for manipulating a live threaded server after fork.

Check it quickly

Save this as forkexec.py and run python3 forkexec.py in a disposable Linux environment. The recorded run used Linux 6.12.76 and Python 3.11.2. This fixture is deliberately single-threaded. It uses one private Python value, one explicitly shared anonymous mapping, a temporary data file, and a pipe for a small result:

from pathlib import Path
from tempfile import TemporaryDirectory
import errno,json,mmap,os,sys

program=r'''
import errno,json,os,sys
original_pid,kept,dropped,report=map(int,sys.argv[1:])
assert os.getpid()==original_pid
try:os.fstat(dropped)
except OSError as error:assert error.errno==errno.EBADF
else:raise AssertionError('close-on-exec descriptor survived')
assert os.read(kept,2)==b'ef'
assert os.environ['XB_FORK_VALUE']=='child-value'
os.write(report,json.dumps({'pid_unchanged':True,'kept_descriptor':'ef','close_on_exec':'closed','explicit_environment':'child-value'}).encode())
'''
with TemporaryDirectory(prefix='xbcatm-fork-') as directory:
    data=Path(directory)/'data';data.write_bytes(b'abcdef')
    descriptor=os.open(data,os.O_RDONLY);dropped=os.dup(descriptor)
    read_end,write_end=os.pipe();os.set_inheritable(descriptor,True);os.set_inheritable(write_end,True)
    assert not os.get_inheritable(dropped)
    private=['parent'];shared=mmap.mmap(-1,8,flags=mmap.MAP_SHARED);shared[:]=b'parent!!'
    assert os.read(descriptor,2)==b'ab'
    sys.stdout.flush();pid=os.fork()
    if pid==0:
        try:
            os.close(read_end);private[0]='child';shared[:]=b'child!!!'
            assert os.read(descriptor,2)==b'cd'
            try:os.execve(str(Path(directory)/'missing-program'),['missing-program'],{})
            except FileNotFoundError:pass
            else:raise AssertionError('missing exec unexpectedly succeeded')
            os.execve(sys.executable,[sys.executable,'-c',program,str(os.getpid()),str(descriptor),str(dropped),str(write_end)],{'XB_FORK_VALUE':'child-value'})
        except BaseException as error:
            os.write(write_end,('ERROR '+repr(error)).encode());os._exit(1)
    os.close(write_end)
    try:
        waited,status=os.waitpid(pid,0);report=os.read(read_end,4096)
        assert waited==pid and os.WIFEXITED(status) and os.WEXITSTATUS(status)==0,report
        result=json.loads(report);assert result['pid_unchanged']
        assert private==['parent'] and shared[:]==b'child!!!'
        assert os.lseek(descriptor,0,os.SEEK_CUR)==6
        print('fork: private Python state diverges, MAP_SHARED changes and open-description offsets remain shared')
        print('missing exec returns to the child; successful exec keeps its PID, honors close-on-exec, and receives an explicit environment')
        print('parent reaped a successful exit with waitpid and retained its original private value')
    finally:
        shared.close()
        for fd in [descriptor,dropped,read_end]:os.close(fd)

Before fork, the parent reads ab from abcdef. The child changes its private Python list and the shared mapping, then reads cd through the inherited descriptor. After the child finishes, the parent’s list still contains its original value, but the shared mapping contains the child’s bytes. Both results are required to describe memory inheritance accurately.

The child first tries to execute a nonexistent file. That ordinary missing-path failure raises FileNotFoundError, and the child continues running its existing program. It then successfully executes Python again with a supplied program and an explicit environment. The example verifies this recoverable failure case; it does not generalize every rare execution failure into the same recovery behavior.

The replacement program compares its PID with the child’s PID passed before exec and finds them equal. Exec has replaced code within the child, not created a third process. The explicitly provided environment contains the fixture value, which the replacement reads directly; it does not depend on a login shell or an implicit profile-loading step.

The retained data descriptor reads ef, showing that the original parent’s read, the child’s read, and the replacement program’s read advanced one shared open-description offset. Meanwhile, the descriptor left non-inheritable fails inspection with EBADF in the replacement. The fixture deliberately marks only the descriptors needed across exec as inheritable.

The parent uses waitpid to collect the child’s status and confirms a normal zero exit. It then observes offset six through its own descriptor and closes its resources. Waiting obtains lifecycle information and reaps the exited child; the child’s numerical PID alone is not a substitute for collecting completion or verifying which launch succeeded.

Make launch ownership explicit

Define which descriptors a child needs, close unused pipe ends, and choose environment values deliberately. An extra inherited pipe writer can prevent a reader from seeing EOF even after the intended producer exits. Descriptor lifetime is therefore part of the communication protocol, not merely a cleanup detail after program startup.

Prefer the runtime’s supported subprocess or spawn interface when it expresses the needed setup. Such interfaces can use mechanisms other than a literal fork/exec pair. Their documented descriptor and environment behavior is the relevant application contract; assuming every launcher follows an identical internal sequence can produce incorrect portability and safety conclusions.

Distinguish launch failure, program failure, and failure to observe completion. A successful exec never returns to the previous program, whereas the replacement can later exit unsuccessfully. Record the actual child status and preserve enough context to identify the attempted command, without logging confidential environment values. That gives launch supervision a verifiable outcome rather than an inferred success.

Sources

  • fork(2), private memory, shared mappings, inherited descriptions, and thread restrictions, checked 2026-09-12.
  • execve(2), image replacement, explicit environment, preserved PID, and close-on-exec, checked 2026-09-12.
  • waitpid(2), collecting child status and reaping an exited child, checked 2026-09-12.