gh-100228: Warn from os.fork() if other threads exist. (#100229)

Not comprehensive, best effort warning. There are cases when threads exist on some platforms that this code cannot detect. macOS when API permissions allow and Linux with a readable /proc procfs present are the currently supported cases where a warning should show up reliably.

Starting with a DeprecationWarning for now, it is less disruptive than something like RuntimeWarning and most likely to only be seen in people's CI tests - a good place to start with this messaging.
This commit is contained in:
Gregory P. Smith 2022-12-29 14:41:39 -08:00 committed by GitHub
parent 2df82db485
commit 894f2c3c16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 283 additions and 66 deletions

View file

@ -733,6 +733,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) {
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(__xor__));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_abc_impl));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_abstract_));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_active));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_annotation));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_anonymous_));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_argtypes_));
@ -753,6 +754,7 @@ _PyStaticObjects_CheckRefcnt(PyInterpreterState *interp) {
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_initializing));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_is_text_encoding));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_length_));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_limbo));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_lock_unlock_module));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_loop));
_PyStaticObject_CheckRefcnt((PyObject *)&_Py_ID(_needs_com_addref_));

View file

@ -219,6 +219,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(__xor__)
STRUCT_FOR_ID(_abc_impl)
STRUCT_FOR_ID(_abstract_)
STRUCT_FOR_ID(_active)
STRUCT_FOR_ID(_annotation)
STRUCT_FOR_ID(_anonymous_)
STRUCT_FOR_ID(_argtypes_)
@ -239,6 +240,7 @@ struct _Py_global_strings {
STRUCT_FOR_ID(_initializing)
STRUCT_FOR_ID(_is_text_encoding)
STRUCT_FOR_ID(_length_)
STRUCT_FOR_ID(_limbo)
STRUCT_FOR_ID(_lock_unlock_module)
STRUCT_FOR_ID(_loop)
STRUCT_FOR_ID(_needs_com_addref_)

View file

@ -725,6 +725,7 @@ extern "C" {
INIT_ID(__xor__), \
INIT_ID(_abc_impl), \
INIT_ID(_abstract_), \
INIT_ID(_active), \
INIT_ID(_annotation), \
INIT_ID(_anonymous_), \
INIT_ID(_argtypes_), \
@ -745,6 +746,7 @@ extern "C" {
INIT_ID(_initializing), \
INIT_ID(_is_text_encoding), \
INIT_ID(_length_), \
INIT_ID(_limbo), \
INIT_ID(_lock_unlock_module), \
INIT_ID(_loop), \
INIT_ID(_needs_com_addref_), \

View file

@ -344,6 +344,8 @@ _PyUnicode_InitStaticStrings(void) {
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_abstract_);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_active);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_annotation);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_anonymous_);
@ -384,6 +386,8 @@ _PyUnicode_InitStaticStrings(void) {
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_length_);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_limbo);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_lock_unlock_module);
PyUnicode_InternInPlace(&string);
string = &_Py_ID(_loop);

View file

@ -13,6 +13,7 @@ import os, sys, time, unittest
import threading
from test import support
from test.support import threading_helper
import warnings
LONGSLEEP = 2
@ -63,12 +64,10 @@ class ForkWait(unittest.TestCase):
prefork_lives = self.alive.copy()
if sys.platform in ['unixware7']:
cpid = os.fork1()
else:
cpid = os.fork()
if cpid == 0:
# Ignore the warning about fork with threads.
with warnings.catch_warnings(category=DeprecationWarning,
action="ignore"):
if (cpid := os.fork()) == 0:
# Child
time.sleep(LONGSLEEP)
n = 0

View file

@ -4577,6 +4577,34 @@ class ForkTests(unittest.TestCase):
assert_python_ok("-c", code)
assert_python_ok("-c", code, PYTHONMALLOC="malloc_debug")
@unittest.skipUnless(sys.platform in ("linux", "darwin"),
"Only Linux and macOS detect this today.")
def test_fork_warns_when_non_python_thread_exists(self):
code = """if 1:
import os, threading, warnings
from _testcapi import _spawn_pthread_waiter, _end_spawned_pthread
_spawn_pthread_waiter()
try:
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
if os.fork() == 0:
assert not ws, f"unexpected warnings in child: {ws}"
os._exit(0) # child
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
# Waiting allows an error in the child to hit stderr.
exitcode = os.wait()[1]
assert exitcode == 0, f"child exited {exitcode}"
assert threading.active_count() == 1, threading.enumerate()
finally:
_end_spawned_pthread()
"""
_, out, err = assert_python_ok("-c", code, PYTHONOPTIMIZE='0')
self.assertEqual(err.decode("utf-8"), "")
self.assertEqual(out.decode("utf-8"), "")
# Only test if the C version is provided, otherwise TestPEP519 already tested
# the pure Python implementation.

View file

@ -5,6 +5,7 @@ from test import support
from test.support import threading_helper
import _thread as thread
import time
import warnings
import weakref
from test import lock_tests
@ -238,9 +239,11 @@ class TestForkInThread(unittest.TestCase):
def fork_thread(read_fd, write_fd):
nonlocal pid
# fork in a thread
pid = os.fork()
if pid:
# Ignore the warning about fork with threads.
with warnings.catch_warnings(category=DeprecationWarning,
action="ignore"):
# fork in a thread (DANGER, undefined per POSIX)
if (pid := os.fork()):
# parent process
return

View file

@ -20,6 +20,7 @@ import subprocess
import signal
import textwrap
import traceback
import warnings
from unittest import mock
from test import lock_tests
@ -563,7 +564,7 @@ class ThreadTests(BaseTestCase):
# Issue #14308: a dummy thread in the active list doesn't mess up
# the after-fork mechanism.
code = """if 1:
import _thread, threading, os, time
import _thread, threading, os, time, warnings
def background_thread(evt):
# Creates and registers the _DummyThread instance
@ -575,10 +576,15 @@ class ThreadTests(BaseTestCase):
_thread.start_new_thread(background_thread, (evt,))
evt.wait()
assert threading.active_count() == 2, threading.active_count()
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
if os.fork() == 0:
assert threading.active_count() == 1, threading.active_count()
os._exit(0)
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
os.wait()
"""
_, out, err = assert_python_ok("-c", code)
@ -598,8 +604,10 @@ class ThreadTests(BaseTestCase):
for i in range(20):
t = threading.Thread(target=lambda: None)
t.start()
pid = os.fork()
if pid == 0:
# Ignore the warning about fork with threads.
with warnings.catch_warnings(category=DeprecationWarning,
action="ignore"):
if (pid := os.fork()) == 0:
os._exit(11 if t.is_alive() else 10)
else:
t.join()
@ -645,10 +653,13 @@ class ThreadTests(BaseTestCase):
@unittest.skipUnless(hasattr(os, 'waitpid'), "test needs os.waitpid()")
def test_main_thread_after_fork_from_nonmain_thread(self):
code = """if 1:
import os, threading, sys
import os, threading, sys, warnings
from test import support
def func():
with warnings.catch_warnings(record=True) as ws:
warnings.filterwarnings(
"always", category=DeprecationWarning)
pid = os.fork()
if pid == 0:
main = threading.main_thread()
@ -659,6 +670,8 @@ class ThreadTests(BaseTestCase):
# we have to flush before exit.
sys.stdout.flush()
else:
assert ws[0].category == DeprecationWarning, ws[0]
assert 'fork' in str(ws[0].message), ws[0]
support.wait_process(pid, exitcode=0)
th = threading.Thread(target=func)
@ -667,7 +680,7 @@ class ThreadTests(BaseTestCase):
"""
_, out, err = assert_python_ok("-c", code)
data = out.decode().replace('\r', '')
self.assertEqual(err, b"")
self.assertEqual(err.decode('utf-8'), "")
self.assertEqual(data, "Thread-1 (func)\nTrue\nTrue\n")
def test_main_thread_during_shutdown(self):
@ -1173,6 +1186,9 @@ class ThreadJoinOnShutdown(BaseTestCase):
else:
os._exit(50)
# Ignore the warning about fork with threads.
with warnings.catch_warnings(category=DeprecationWarning,
action="ignore"):
# start a bunch of threads that will fork() child processes
threads = []
for i in range(16):
@ -1194,6 +1210,10 @@ class ThreadJoinOnShutdown(BaseTestCase):
threads.append(t)
t.start()
try:
# Ignore the warning about fork with threads.
with warnings.catch_warnings(category=DeprecationWarning,
action="ignore"):
pid = os.fork()
if pid == 0:
# check that threads states have been cleared
@ -1203,7 +1223,7 @@ class ThreadJoinOnShutdown(BaseTestCase):
os._exit(52)
else:
support.wait_process(pid, exitcode=51)
finally:
for t in threads:
t.join()

View file

@ -1490,6 +1490,8 @@ def active_count():
enumerate().
"""
# NOTE: if the logic in here ever changes, update Modules/posixmodule.c
# warn_about_fork_with_threads() to match.
with _active_limbo_lock:
return len(_active) + len(_limbo)

View file

@ -0,0 +1,5 @@
A :exc:`DeprecationWarning` may be raised when :func:`os.fork()` or
:func:`os.forkpty()` is called from multi-threaded processes. Forking
with threads is unsafe and can cause deadlocks, crashes and subtle
problems. Lack of a warning does not indicate that the fork call was
actually safe, as Python may not be aware of all threads.

View file

@ -25,6 +25,9 @@
#include "structmember.h" // for offsetof(), T_OBJECT
#include <float.h> // FLT_MAX
#include <signal.h>
#ifndef MS_WINDOWS
#include <unistd.h>
#endif
#ifdef HAVE_SYS_WAIT_H
#include <sys/wait.h> // W_STOPCODE
@ -871,6 +874,46 @@ test_thread_state(PyObject *self, PyObject *args)
Py_RETURN_NONE;
}
#ifndef MS_WINDOWS
static PyThread_type_lock wait_done = NULL;
static void wait_for_lock(void *unused) {
PyThread_acquire_lock(wait_done, 1);
PyThread_release_lock(wait_done);
PyThread_free_lock(wait_done);
wait_done = NULL;
}
// These can be used to test things that care about the existence of another
// thread that the threading module doesn't know about.
static PyObject *
spawn_pthread_waiter(PyObject *self, PyObject *Py_UNUSED(ignored))
{
if (wait_done) {
PyErr_SetString(PyExc_RuntimeError, "thread already running");
return NULL;
}
wait_done = PyThread_allocate_lock();
if (wait_done == NULL)
return PyErr_NoMemory();
PyThread_acquire_lock(wait_done, 1);
PyThread_start_new_thread(wait_for_lock, NULL);
Py_RETURN_NONE;
}
static PyObject *
end_spawned_pthread(PyObject *self, PyObject *Py_UNUSED(ignored))
{
if (!wait_done) {
PyErr_SetString(PyExc_RuntimeError, "call _spawn_pthread_waiter 1st");
return NULL;
}
PyThread_release_lock(wait_done);
Py_RETURN_NONE;
}
#endif // not MS_WINDOWS
/* test Py_AddPendingCalls using threads */
static int _pending_callback(void *arg)
{
@ -3207,6 +3250,10 @@ static PyMethodDef TestMethods[] = {
{"test_get_type_name", test_get_type_name, METH_NOARGS},
{"test_get_type_qualname", test_get_type_qualname, METH_NOARGS},
{"_test_thread_state", test_thread_state, METH_VARARGS},
#ifndef MS_WINDOWS
{"_spawn_pthread_waiter", spawn_pthread_waiter, METH_NOARGS},
{"_end_spawned_pthread", end_spawned_pthread, METH_NOARGS},
#endif
{"_pending_threadfunc", pending_threadfunc, METH_VARARGS},
#ifdef HAVE_GETTIMEOFDAY
{"profile_int", profile_int, METH_NOARGS},

View file

@ -72,6 +72,8 @@
*/
#if defined(__APPLE__)
#include <mach/mach.h>
#if defined(__has_builtin)
#if __has_builtin(__builtin_available)
#define HAVE_BUILTIN_AVAILABLE 1
@ -6745,6 +6747,104 @@ os_register_at_fork_impl(PyObject *module, PyObject *before,
}
#endif /* HAVE_FORK */
// Common code to raise a warning if we detect there is more than one thread
// running in the process. Best effort, silent if unable to count threads.
// Constraint: Quick. Never overcounts. Never leaves an error set.
//
// This code might do an import, thus acquiring the import lock, which
// PyOS_BeforeFork() also does. As this should only be called from
// the parent process, it is in the same thread so that works.
static void warn_about_fork_with_threads(const char* name) {
// TODO: Consider making an `os` module API to return the current number
// of threads in the process. That'd presumably use this platform code but
// raise an error rather than using the inaccurate fallback.
Py_ssize_t num_python_threads = 0;
#if defined(__APPLE__) && defined(HAVE_GETPID)
mach_port_t macos_self = mach_task_self();
mach_port_t macos_task;
if (task_for_pid(macos_self, getpid(), &macos_task) == KERN_SUCCESS) {
thread_array_t macos_threads;
mach_msg_type_number_t macos_n_threads;
if (task_threads(macos_task, &macos_threads,
&macos_n_threads) == KERN_SUCCESS) {
num_python_threads = macos_n_threads;
}
}
#elif defined(__linux__)
// Linux /proc/self/stat 20th field is the number of threads.
FILE* proc_stat = fopen("/proc/self/stat", "r");
if (proc_stat) {
size_t n;
// Size chosen arbitrarily. ~60% more bytes than a 20th column index
// observed on the author's workstation.
char stat_line[160];
n = fread(&stat_line, 1, 159, proc_stat);
stat_line[n] = '\0';
fclose(proc_stat);
char *saveptr = NULL;
char *field = strtok_r(stat_line, " ", &saveptr);
unsigned int idx;
for (idx = 19; idx && field; --idx) {
field = strtok_r(NULL, " ", &saveptr);
}
if (idx == 0 && field) { // found the 20th field
num_python_threads = atoi(field); // 0 on error
}
}
#endif
if (num_python_threads <= 0) {
// Fall back to just the number our threading module knows about.
// An incomplete view of the world, but better than nothing.
PyObject *threading = PyImport_GetModule(&_Py_ID(threading));
if (!threading) {
PyErr_Clear();
return;
}
PyObject *threading_active =
PyObject_GetAttr(threading, &_Py_ID(_active));
if (!threading_active) {
PyErr_Clear();
Py_DECREF(threading);
return;
}
PyObject *threading_limbo =
PyObject_GetAttr(threading, &_Py_ID(_limbo));
if (!threading_limbo) {
PyErr_Clear();
Py_DECREF(threading);
Py_DECREF(threading_active);
return;
}
Py_DECREF(threading);
// Duplicating what threading.active_count() does but without holding
// threading._active_limbo_lock so our count could be inaccurate if
// these dicts are mid-update from another thread. Not a big deal.
// Worst case if someone replaced threading._active or threading._limbo
// with non-dicts, we get -1 from *Length() below and undercount.
// Nobody should, but we're best effort so we clear errors and move on.
num_python_threads = (PyMapping_Length(threading_active)
+ PyMapping_Length(threading_limbo));
PyErr_Clear();
Py_DECREF(threading_active);
Py_DECREF(threading_limbo);
}
if (num_python_threads > 1) {
PyErr_WarnFormat(
PyExc_DeprecationWarning, 1,
#ifdef HAVE_GETPID
"This process (pid=%d) is multi-threaded, "
#else
"This process is multi-threaded, "
#endif
"use of %s() may lead to deadlocks in the child.",
#ifdef HAVE_GETPID
getpid(),
#endif
name);
PyErr_Clear();
}
}
#ifdef HAVE_FORK1
/*[clinic input]
@ -6771,6 +6871,7 @@ os_fork1_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("fork1");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}
@ -6810,6 +6911,7 @@ os_fork_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("fork");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}
@ -7479,6 +7581,7 @@ os_forkpty_impl(PyObject *module)
/* child: this clobbers and resets the import lock. */
PyOS_AfterFork_Child();
} else {
warn_about_fork_with_threads("forkpty");
/* parent: release the import lock. */
PyOS_AfterFork_Parent();
}