Skip to content

Hold the GIL while releasing shared_ptr_deleter's python handle. - #11

Closed
jloy wants to merge 2 commits into
boostorg:developfrom
jloy:develop
Closed

Hold the GIL while releasing shared_ptr_deleter's python handle.#11
jloy wants to merge 2 commits into
boostorg:developfrom
jloy:develop

Conversation

@jloy

@jloy jloy commented Nov 3, 2014

Copy link
Copy Markdown

Python requires that the GIL be held while releasing objects, but requiring
that all clients of boost::shared_ptr-held types do so explicitly is
error-prone. In the case where the wrapped type is consumed in a library that
you don't control, it might not even be possible to do correctly. Instead,
have shared_ptr_deleter acquire the GIL on client's behalf at exactly the time
when it is necessary.

There's already an open trac issue for this (https://svn.boost.org/trac/boost/ticket/8290)
but the documentation for handle::release doesn't specify that it never throws, so the
patch in trac would fail to properly release the GIL state in that circumstance.

Python requires that the GIL be held while releasing objects, but requiring
that all clients of boost::shared_ptr-held types do so explicitly is
error-prone.  In the case where the wrapped type is consumed in a library that
you don't control, it might not even be possible to do correctly.  Instead,
have shared_ptr_deleter acquire the GIL on client's behalf at exactly the time
when it is necessary.
@hotgloupi

Copy link
Copy Markdown

Hi,

I don't understand why the GIL has to be explicitly held when down counting an object ref count, I've never done so, and never encountered a crash. Is it luck ? Or is it that bad things happen in the context of multiple threads (and ThreadStates) ?

The patch looks good (except the "m_" thing which I've never seen elsewhere in boost codebase).

@jloy

jloy commented Nov 4, 2014

Copy link
Copy Markdown
Author

Multiple threads is indeed the issue. In our case it's not about multiple ThreadStates exactly, but rather no thread state. We release the GIL when calling into purely C++ libraries so we can actually utilize multiple cores concurrently. This sets the current thread state to NULL. In that (seemingly) C++-only code path, dropping a shared_ptr might run the shared_ptr_deleter, which might wind it's way into subtype_dealloc inside of Python itself, which wants to access the current PyThreadState, which is NULL. This will crash. Someone along the way needs to re-acquire the GIL before calling back into Python, establishing the proper current thread state.

Luck can certainly also play a role. We were using this code for years without issue until we started hitting a crash regularly following an update from Python 2.6 to 2.7.

If you're not releasing the GIL yourself or shipping shared_ptr's between Python and non-Python threads, it's likely that you're safe from this particular issue.

Does that answer your questions? Thanks for taking the time to look this over.

@hotgloupi

Copy link
Copy Markdown

Thanks for such a detailed reply ! If I understand correctly, some C++ code
that runs in a separate thread might drop a shared_ptr, which was the last
reference of it, and thus calling indirectly the CPython API.

Correct me if I'm wrong, but I see one problem with this patches: it
imposes a GIL lock/unlock even on code instrumented by python (not in a
thread), so this is a performance issue (in my case, threads never interact
directly with python objects, yet).

An optimization could be to check the thread state value before locking the
GIL, so mono-threaded code won't suffer too much penalty, am I right ?

@jloy

jloy commented Nov 5, 2014

Copy link
Copy Markdown
Author

Yes, you are right that even single-threaded clients would pay the overhead of GIL acquire/release and your suggestion to elide the GIL operations in all cases that they're not necessary would be ideal. Unfortunately, the thread state access API (e.g. PyThreadState_Get) requires that the GIL be held before calling them. We would have to hold the GIL in order to check if we have to hold the GIL.

However, we can still do a little bit better than my original patch. First, we can omit this entirely if the Python interpreter is compiled without threading support. We could also make a runtime check to see if the client has called PyEval_InitThreads and omit the GIL access. (Technically, we can only make this check in Python 2.4 or newer using PyEval_ThreadsInitialized. Do you happen to know what the minimum supported version for boost is?)

I'll see if I can update the patch in the next couple of days.

@hotgloupi

Copy link
Copy Markdown

Thanks for looking into it ! It was just thinking out loud. Anyway, the
solution you propose sounds good to me.
If I remember correctly, boost python supports at least python 2.2, but I'm
pretty sure that nobody would object moving it to 2.4.
In the worst scenario we could do this optimization only for python >= 2.4
...

In any case, it would be great if official contributors could have an eye
on this !

…ding

enabled & a "new enough" Python version.

Only compile in this behavior for Python 2.4 & newer (because
PyEval_ThreadsInitialized was added in 2.4) and only if Python was compiled
with threading support (WITH_THREAD) so that Boost Python continues to work
as-is for other users.

Only acquire the GIL if threading support has been initialized
at runtime to help avoid overhead in clients that don't require it.
@jloy

jloy commented Nov 11, 2014

Copy link
Copy Markdown
Author

Ok, I've updated things to work as described above. Anyone that doesn't enable threading or is still using an older version of Python should be unaffected by this change.

@eudoxos

eudoxos commented Sep 27, 2015

Copy link
Copy Markdown

Great pull request. What is the status, is there a way to push it forward?

For the record, link to old ticket: https://svn.boost.org/trac/boost/ticket/8290 and a discussion at c++-sig where some resistance was met.

@jloy jloy closed this Mar 12, 2016
@jloy
jloy deleted the develop branch March 12, 2016 00:01
@jloy
jloy restored the develop branch March 12, 2016 00:11
@jloy jloy reopened this Mar 12, 2016
@stefanseefeld
stefanseefeld force-pushed the develop branch 2 times, most recently from 8ccdcff to 3ace4a0 Compare October 8, 2016 17:34
@eudoxos

eudoxos commented Oct 23, 2016

Copy link
Copy Markdown

Ping?

@stefanseefeld

Copy link
Copy Markdown
Member

I'm aware of this PR. As has been pointed out earlier there were objections raised to this in http://code.activestate.com/lists/python-cplusplus-sig/17196/, as such a change may cause a deadlock.
Can you convince me that this isn't the case ? If not, could the code be reworked a bit to make this new policy optional, so users could pick explicitly whether they want to use a lock or not ?

@eudoxos

eudoxos commented Oct 23, 2016

Copy link
Copy Markdown

I see the objection as ungrounded; if there were potential deadlock with this change, there would be very likely crash with the current state - when Python object is deleted (or in general, when Python API is called), the acting thread must hold the GIL (I don't have citation now, but it is in Python docs written very clearly IIRC), otherwise other thread might simultaneously call Python API possibly causing crash, if both happen to touch the same memory structures.

If the behavior were selectable via policies, I'd need some guidance how to do it; or maybe someone else knowing that template jungle (meant in a good way) better could code it quickly.

@nwhitehorn

Copy link
Copy Markdown

The only way to reliably avoid deadlocks here would be to have shared_ptr_deleter add reset requests to a global queue that is processed when the GIL is acquired in the normal course of events. This would require a special boost python wrapper for the GIL that knows to check the queue, but that isn't so bad.

@eudoxos

eudoxos commented Oct 13, 2017

Copy link
Copy Markdown

@nwhitehorn, I don't see how this would cause deadlock. Repeating, if GIL is in the same thread, the lock is a no-op, so no deadlock. If another thread tries to acquire it, it will wait till it is released. If it is already held in another thread, the request for GIL will be pending until the other thread releases it.

If there are cross-dependencies between threads leading to deadlock, then they would be crashing python as well (if not, only by accident).

Please reconsider this. I am now solving another bug due to this erratic design decision, and I am not very happy about it. (It might well be that boost::python is past its zenith in terms of being set in stone including its mistakes, for the fear of not breaking existing broken code and pybind11 is the rising star. Who knows...)

@nwhitehorn

nwhitehorn commented Oct 13, 2017

Copy link
Copy Markdown

The issue is that your code can't acquire any other locks while holding the GIL, because the GIL acquisition via shared_ptr deleter can happen at unpredictable times. This is an issue because all boost::python code is called with the GIL held unless explicitly released.

Suppose that you have a lock I will call A. The lock will be acquired in a C++ function called by boost::python I will call B(), but has not been yet -- though B() has started.

Separately, some other thread (C) is running and holds A. A shared_ptr held by C() goes out of scope while still holding A, which happens to have come from Python. The shared_ptr deleter then tries to acquire the GIL. That blocks, since B() is holding the GIL still.

Now B() tries the acquire lock A. That blocks on the completion of C() -- which is now held on the completion of B(), which is waiting for C(). And now you have a deadlock. Note that the only thing required for this to happen is that you have a lock -- any lock -- acquired by C++ code called into by boost python.

The problem arises in that shared_ptr deleters run in unpredictable contexts, so acquiring big locks that may cover large blocks of code like GIL as part of shared_ptr operations is dangerous. Practically, one of the following needs to be true:

  1. You can never let a shared_ptr go out of scope when any locks besides the GIL are held. In general, this is near-impossible, but the reset queue idea I mentioned actually does let this happen.
  2. Make sure that any call into C++ from Python releases the GIL immediately, so that no C++ locks are ever acquired while holding the GIL. To make this reliable, this would need to get pushed into boost::python. All entry points into the interpreter then need to acquire the GIL, including explicit CPython calls not part of boost::python, which breaks the boost::python API.

@lightmare

Copy link
Copy Markdown

I think that potential for deadlocks is not a valid argument against this PR.

In the example @nwhitehorn presented, whether shared_ptr_deleter acquires the GIL is secondary, the application is asking for trouble either way. As long as shared_ptr_deleter does not acquire the GIL, whoever released the GIL must make sure no shared pointers go out before it's re-acquired (1). The thread C() dropped a pointer while not holding GIL -- shared_ptr_deleter now has a choice:

  • try to acquire the GIL and hope it won't deadlock, or
  • DECREF without the GIL and hope it won't crash.

Besides, trying to acquire any other lock while holding the GIL sounds like a bad strategy. It's a classic example where two threads are trying to acquire two locks, but in different order: thread B() goes GIL-->A, thread C() goes A-->GIL. The GIL is probably the most globalest lock in the application, so it should be acquired last.

(1) or more precisely, no shared pointers holding Python objects whose tp_dealloc is not thread-safe, e.g. containers (global trash can), unicode (global interned dict), float (global free list), ...

Reset queue sounds neat, might even be easy to implement with Py_AddPendingCall, but it seems a bit overkill compared to this PR.

@jloy jloy closed this Mar 5, 2024
pixar-oss pushed a commit to PixarAnimationStudios/OpenUSD that referenced this pull request Sep 14, 2024
…n C++

This crash would occur because the shared_ptr_deleter that boost::python
attaches to shared_ptr-held types would call Python API without holding
the GIL. This change avoids that by ensuring the GIL is held in this case.

This is a known issue in upstream boost::python. It was discussed and
reported in a few different places:

https://mail.python.org/pipermail/cplusplus-sig/2007-June/012130.html
boostorg/python#11

Pixar has been using this patch in internal builds since 2014. It was
never accepted upstream due to concerns about potential deadlocks
when a shared_ptr is being destroyed in one thread while another thread
is holding on to the GIL (see the GitHub PR above for more details).
While this may generally be true, Python-wrapped functions in OpenUSD
that invoke multi-threading typically ensure the GIL is dropped first
to avoid scenarios like the above.

This patch was originally written by John Loy (@jloy).

(Internal change: 2340607)
buddly27 pushed a commit to untwine/pxr-boost that referenced this pull request Nov 20, 2024
…n C++

This crash would occur because the shared_ptr_deleter that boost::python
attaches to shared_ptr-held types would call Python API without holding
the GIL. This change avoids that by ensuring the GIL is held in this case.

This is a known issue in upstream boost::python. It was discussed and
reported in a few different places:

https://mail.python.org/pipermail/cplusplus-sig/2007-June/012130.html
boostorg/python#11

Pixar has been using this patch in internal builds since 2014. It was
never accepted upstream due to concerns about potential deadlocks
when a shared_ptr is being destroyed in one thread while another thread
is holding on to the GIL (see the GitHub PR above for more details).
While this may generally be true, Python-wrapped functions in OpenUSD
that invoke multi-threading typically ensure the GIL is dropped first
to avoid scenarios like the above.

This patch was originally written by John Loy (@jloy).

(Internal change: 2340607)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants