GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — gh-128679: Fix tracemalloc.stop() race condition by vstinner · Pull Request #128710 · python/cpython

Conversation

@vstinner

@vstinner vstinner commented Jan 10, 2025 •
edited by bedevere-app Bot
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

Check again 'tracemalloc_config.tracing' once the GIL is held in tracemalloc_raw_alloc() and PyTraceMalloc_Track(), since another thread can call tracemalloc.stop() during PyGILState_Ensure() call.

  • Issue: Race condition in tracemalloc causes segfaults #128679

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@vstinner
pythongh-128679: Fix tracemalloc.stop() race condition
8f58d77
Check again 'tracemalloc_config.tracing' once the GIL is held in
tracemalloc_raw_alloc() and PyTraceMalloc_Track(), since another
thread can call tracemalloc.stop() during PyGILState_Ensure() call.
@vstinner vstinner added needs backport to 3.12 only security fixes needs backport to 3.13 only security fixes labels Jan 10, 2025
@bedevere-app bedevere-app Bot added the awaiting core review label Jan 10, 2025
@bedevere-app bedevere-app Bot mentioned this pull request Jan 10, 2025
Race condition in tracemalloc causes segfaults #128679
Closed
@vstinner
Define 'res' in PyTraceMalloc_Track()
1e3eac3
@vstinner vstinner mentioned this pull request Jan 10, 2025
gh-128679: fix race condition in tracemalloc #128695
Closed
@ZeroIntensity

ZeroIntensity commented Jan 10, 2025

Copy link
Copy Markdown
Member

Should we include a test based off the reproducer in the issue?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

vstinner added 2 commits January 13, 2025 17:03
@vstinner
Fix the race condition in the PyMem_RawFree() hook
59fd8de
* Hold the table lock while calling _PyTraceMalloc_Stop().
* Add tracemalloc_raw_free().
@vstinner
Add test
0e6de65
@vstinner

vstinner commented Jan 13, 2025

Copy link
Copy Markdown
Member Author

My fix was incomplete, the PyMem_RawFree() hook also had the bug:

    Fix the race condition in the PyMem_RawFree() hook
    
    * Hold the table lock while calling _PyTraceMalloc_Stop().
    * Add tracemalloc_raw_free().

Should we include a test based off the reproducer in the issue?

Ok, I added a test. It was a good idea, I found PyMem_RawFree() bug with it :-)

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

vstinner added 5 commits January 13, 2025 17:21
@vstinner
Fix test on Windows
48e172d
@vstinner
Fix refleak on error
146167b
@vstinner
test: add #include <windows.h>
aceff9b
@vstinner
Skip test on WASI
61edb4c
@vstinner
Rewrite the test without sleep
9df058a
@vstinner

vstinner commented Jan 14, 2025

Copy link
Copy Markdown
Member Author

@ZeroIntensity: Would you mind to review the PR which now has a test?

ZeroIntensity reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner
revert unwanted change
ecfa30b
@ZeroIntensity
ZeroIntensity self-requested a review January 14, 2025 16:52
@ZeroIntensity

ZeroIntensity commented Jan 14, 2025

Copy link
Copy Markdown
Member

Will get to it a little later today :)

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

ZeroIntensity
ZeroIntensity reviewed Jan 14, 2025
View reviewed changes

@ZeroIntensity ZeroIntensity left a comment

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not too comfortable relying on incidental lock ordering with the tables lock for synchronization here. Everywhere tracemalloc_config.tracing is used the GIL is held, right? We should be able to rely on the GIL for synchronization. Oherwise, let's either use a dedicated lock for it or one of the _Py_atomic APIs.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Modules/_testcapimodule.c
{
PyTraceMalloc_Track(123, 10, 1);

PyThread_type_lock lock = (PyThread_type_lock)data;

@ZeroIntensity ZeroIntensity Jan 14, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a lot easier to use a PyMutex, considering you don't have to heap allocate it. I think it works without a thread state if you use _PyMutex_LockFlags(mutex, _Py_LOCK_DONT_DETACH)

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Modules/_testcapimodule.c Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread Python/tracemalloc.c
Comment on lines +636 to +638
else {
// gh-128679: tracemalloc.stop() was called by another thread
}

@ZeroIntensity ZeroIntensity Jan 14, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the else:

Suggested change
else {
// gh-128679: tracemalloc.stop() was called by another thread
}
// gh-128679: If we're not tracing, then tracemalloc.stop() was called by another thread

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread Python/tracemalloc.c

// Lock to synchronize with tracemalloc_raw_free() which checks
// 'tracing' while holding the lock.
TABLES_LOCK();

@ZeroIntensity ZeroIntensity Jan 14, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said in the other PR, relying on lock ordering like this is really error prone. If we hold the GIL, then we should be able to use tracemalloc_config.tracing without a lock, right?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@vstinner vstinner Jan 15, 2025

Copy link
Copy Markdown
Member Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said in the other PR, relying on lock ordering like this is really error prone.

Would you mind to elaborate?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@ZeroIntensity ZeroIntensity Jan 15, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment: #128710 (comment)

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

Everywhere tracemalloc_config.tracing is used the GIL is held, right? We should be able to rely on the GIL for synchronization.

This PR adds tracemalloc_raw_free() which is called without holding the GIL and cannot acquire the GIL:

     /* GIL cannot be locked in PyMem_RawFree() because it would introduce
        a deadlock in _PyThreadState_DeleteCurrent(). */

So I decided to reuse TABLES_LOCK() to access tracemalloc_config.tracing in tracemalloc_raw_free(). For that, I also modified _PyTraceMalloc_Stop() to use TABLES_LOCK() while modifying tracemalloc_config.tracing (and the different tables).

tracemalloc_free() is called with the GIL held. I added tracemalloc_raw_free() to add the logic to check tracemalloc_config.tracing to handle tracemalloc.stop() race condition.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner
Fix refleak in the test
997148b
@ZeroIntensity

ZeroIntensity commented Jan 15, 2025

Copy link
Copy Markdown
Member

So I decided to reuse TABLES_LOCK() to access tracemalloc_config.tracing in tracemalloc_raw_free(). For that, I also modified _PyTraceMalloc_Stop() to use TABLES_LOCK() while modifying tracemalloc_config.tracing (and the different tables).

Right, so this is only safe because of lock-ordering. This approach with the tables lock only works for this case because we assume the only thread that can modify tracemalloc_config.tracing during shutdown also holds the tables lock. But, that's not always true, because not all reads and writes are locked. For example, _PyTraceMalloc_IsTracing and _PyTraceMalloc_ClearTraces are both called without the tables lock held, so that will race. You could fix that by wrapping them with the tables lock too, but I think the much cleaner solution would be to use a dedicated lock (or atomics) for tracemalloc_config.tracing.

My other concern is re-entrancy and lock-ordering deadlocks. There are two cases here, depending on what PyThread_acquire_lock does when waiting:

  • If PyThread_acquire_lock releases the GIL while waiting, then we need to add an extra check for tracemalloc_config.tracing every time we use TABLES_LOCK(), because the field could have changed while the GIL was released.
  • If PyThread_acquire_lock doesn't release the GIL, then that's even worse. Imagine thread 1 is waiting on the GIL, and thread 2 holds the GIL. Thread 2 calls into _PyTraceMalloc_Stop, grabs the table lock, and then releases the GIL via an object's finalizer when clearing. Thread 2 is now waiting to re-acquire the GIL. Then, thread 1 takes the GIL and starts waiting on the tables lock. Now we're in a deadlock! Alternatively, an object's finalizer could attempt to acquire the tables lock, which will also deadlock.

Either way, it's problematic.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@tom-pytel

tom-pytel commented Jan 15, 2025

Copy link
Copy Markdown
Contributor
  • If PyThread_acquire_lock releases the GIL while waiting

PyThread_acquire_lock() can release the GIL? I only see that explicitly in PyThread_acquire_lock_timed_with_retries() which is not called by TABLES_LOCK(), what am I missing?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

If PyThread_acquire_lock releases the GIL while waiting

It doesn't release the GIL.

If PyThread_acquire_lock doesn't release the GIL, then that's even worse. Imagine thread 1 is waiting on the GIL, and thread 2 holds the GIL. Thread 2 calls into _PyTraceMalloc_Stop, grabs the table lock, and then releases the GIL via an object's finalizer when clearing.

_PyTraceMalloc_Stop() cannot release the GIL. It calls _Py_hashtable_clear() on these tables:

  • tracemalloc_traces: value_destroy_func=raw_free().
  • tracemalloc_domains: value_destroy_func=_Py_hashtable_destroy().
  • tracemalloc_tracebacks: key_destroy_func=raw_free().
  • tracemalloc_filenames: key_destroy_func=tracemalloc_clear_filename() which calls Py_DECREF() (filenames are Python str objects).

Py_DECREF(filename) cannot release the GIL, it's a simple Python str object.

tracemalloc is designed around "low-level" functions like the Py_hashtable API which should not release the GIL. Only the PyMem_RawMalloc() hook and PyTraceMalloc_Track() function explicitly acquire/release the GIL.


The tricky part is to remove a trace in PyMem_RawFree() hook which is called without holding the GIL, and it cannot acquire the GIL temporarily.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@ZeroIntensity

ZeroIntensity commented Jan 15, 2025

Copy link
Copy Markdown
Member

Generally, things release the GIL when they're waiting on something, like a lock. PyMutex releases it by default, but I'm not sure if PyThread_acquire_lock does the same. But, if it doesn't, then that's significantly worse because we're prone to deadlocks.

_PyTraceMalloc_Stop() cannot release the GIL.

Ok, good to know. I was under the impression that any Py_DECREF could release the GIL because it could trigger a garbage collection, which will in turn call other finalizers, but I guess not.

Anyways, the fix here would be a lot simpler (and less ambiguous) if we went with a dedicated lock for tracing, but if we're really opposed to doing that, I think we could setup a pending call with Py_AddPendingCall that will eventually deallocate it on the main thread.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

Otherwise, let's either use a dedicated lock for it or one of the _Py_atomic APIs.

There are multiple constraints.

  • (A) Only a single thread must access tracemalloc tables at the same time. TABLES_LOCK() is used for ordering operations on tables.
  • (B) tracemalloc must no longer add traces as soon as _PyTraceMalloc_Stop() is called: as soon as tracemalloc_config.tracing is zero.

(B) means that tracemalloc_config.tracing must not be set while tables are modified.

My fix for the PyMem_RawFree() hook combines (A) and (B) constraints by using TABLES_LOCK() for both, since the GIL cannot be used.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

PyThread_acquire_lock() is a thin wrapper to pthread API which is not aware of the GIL.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@tom-pytel

tom-pytel commented Jan 15, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

Generally, things release the GIL when they're waiting on something, like a lock. PyMutex releases it by default, but I'm not sure if PyThread_acquire_lock does the same. But, if it doesn't, then that's significantly worse because we're prone to deadlocks.

Are you sure about PyMutex? Worst thing I see in _PyMutex_LockTimed() is a call to Py_MakePendingCalls() which I thought was not supposed to release GIL and only actually execute in main thread?

EDIT: Never mind, found it.

ZeroIntensity reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@ZeroIntensity

ZeroIntensity commented Jan 15, 2025

Copy link
Copy Markdown
Member

TABLES_LOCK() is used for ordering operations on tables.

Well, sort of. It's used for protecting the operations from multithreaded races, not necessarily synchronizing the threads in a certain order.

Anyways--the fix works, but only for the specific test. I'd rather dedicated locks or atomics because:

  • This will still race with PyTraceMalloc_Track if it's called without a thread state, because the initial tracemalloc_config.tracing read is done without the GIL or tables lock held.
  • This will likely end up being a maintenance burden; if we ever add a case where the GIL is acquired or re-acquired while holding the tables lock, then we're going to be cloudy with a chance of deadlocks.

So, I'm willing to approve this as a bandaid for 3.12, but if we go with this let's try to get a better fix in 3.13 and main (where _Py_atomic and PyMutex are available).

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

Here is something different: I wrote PR gh-128888 to redesign tracemalloc locking. It holds TABLES_LOCK() longer and uses it to protect tracemalloc_config.tracing. It doesn't fix #128679 but prepares the code to be able to fix the issue in a reliable way.

ZeroIntensity reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

ZeroIntensity
ZeroIntensity reviewed Jan 15, 2025
View reviewed changes
Comment thread Python/tracemalloc.c
@@ -1317,9 +1353,16 @@ PyTraceMalloc_Track(unsigned int domain, uintptr_t ptr,

@ZeroIntensity ZeroIntensity Jan 15, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's either lock the read above this or remove it entirely, then LGTM for 3.12.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@vstinner vstinner Jan 15, 2025

Copy link
Copy Markdown
Member Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote #128897 to fix Python 3.12 and 3.13.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

ZeroIntensity reacted with thumbs up emoji
All reactions
  • 👍 1 reaction
@vstinner

vstinner commented Jan 15, 2025

Copy link
Copy Markdown
Member Author

I wrote a different fix based on my refactoring that I just merged: #128893.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@vstinner vstinner closed this Jan 15, 2025
@vstinner
vstinner deleted the tracemalloc branch January 15, 2025 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@ZeroIntensity ZeroIntensity ZeroIntensity left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

awaiting core review needs backport to 3.12 only security fixes needs backport to 3.13 only security fixes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

3 participants

@vstinner @ZeroIntensity @tom-pytel
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.