Skip to content

Commit

Permalink
DOC: Update to more accurately describe callback actions.
Browse files Browse the repository at this point in the history
  • Loading branch information
congma committed Mar 19, 2021
1 parent 08f0772 commit ef1d090
Show file tree
Hide file tree
Showing 2 changed files with 45 additions and 34 deletions.
66 changes: 39 additions & 27 deletions doc/source/reentrancy.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ has yielded at some point.
.. note::
In summary,

* the callback's action is "mostly" reentrant, while
* the callback's action is "mostly" re-entrant but no more reentrant than
the callback itself, while
* for the methods, re-entering is an exception.


Expand All @@ -34,25 +35,29 @@ automatically <introduction:using a callback>` on each evicted item (the
key-value pair removed because the capacity is reached). Although integrated
into the workings of the :class:`LRUDict` object, the callback is beyond its
control and subject to :ref:`caveats <introduction:caveats with callbacks>`.
It is generally not up to the inner workings of :class:`LRUDict` to handle
every situation where the callback may misbehave; this is a consequence of
Rice's Theorem.

Nevertheless, a degree of re-entrancy pertaining to the action of callbacks is
supported, provided that

1. every entrance into the body of :class:`LRUDict` methods is eventually
paired with an exit, and that
2. the callback does not invalidate the running programme otherwise.
2. the callback is otherwise re-entrant in and by itself.

It is up to the programmer to ensure that the callback should behave thus.

Rule 1 means that the callback is allowed to redirect the control flow towards
Rule 1 means that the callback is *allowed* to redirect the control flow towards
re-entering the :class:`LRUDict` object while it is being called by such an
object upon item eviction. This is obviously an error-prone situation. However,
in practice a runaway callback typically hit the recursion limit first before
anything interesting happens. For coroutines, if too many of them yield inside
the callback before returning, there's still the hard limit of
:code:`USHRT_MAX` currently-pending entrances (typically 65535). If the
object upon item eviction. This is obviously an error-prone situation. It is
partially mitigated by hitting Python's recursion (or more precisely,
call-stack) limit, which is understood by :class:`LRUDict`'s internal routines
and propagated whenever possible. For coroutines, if too many of them yield
inside the callback before returning, there's still the hard limit of
:code:`USHRT_MAX` currently-pending entrances (typically 65535). If the
current-pending counter is about to be saturated, the purge will simply be
omitted.
skipped.

As an illustration, it is very easy to write an "amplification attack" version
of the callback, where for each evicted key it inserts more unique keys back
Expand Down Expand Up @@ -92,44 +97,49 @@ greater control over callback execution. This is normally not required, but in
certain cases the default (a fairly large value, 8192) may not work well, and a
much lower limit may be desirable.

This situation can happen if in a multi-threaded environment, the callback
performs GIL release-acquire cycles (typically by doing I/O). If there's a
large number of them pending at the same time, each failure to acquire the GIL
causes blocking for the thread. Since only one thread can acquire the GIL, most
threads already executing the callback will still spend time waiting in the
callback, and little progress can be made overall.
One of the possible situations is the case when, in a multi-threaded
environment, the callback performs GIL release-acquire cycles (typically by
doing I/O). If there's a large number of them pending at the same time, each
failure to acquire the GIL causes blocking for the thread. Since only one
thread can acquire the GIL, most threads already executing in the callback will
still spend time waiting in the callback, and little progress can be made
overall.

In this particular scenario, lowering :attr:`~LRUDict._max_pending_callbacks`
helps. If the pending callbacks have already saturated, any new entrance into
the "purge" section will not touch the evicted items in the queue, but instead
returns almost immediately (i.e. progress is made). The queue will eventually
be cleared, if not by calling :meth:`~LRUDict.purge` explicitly.
helps. If the pending-callback count has already saturated, any new entrance
into the "purge" section will not touch the evicted items in the queue, but
instead returns almost immediately (i.e. progress is made). The queue will
eventually be cleared, if not by calling :meth:`~LRUDict.purge` explicitly.

However, there are two major downsides of lowering the
:attr:`~LRUDict._max_pending_callbacks`:

1. The queue will not be purged as aggressively, so sometimes it may be
worthwhile to check if there are stuck items.
2. If the callback behaves like the ":ref:`amplification attack <amp-attack>`"
example above, it will likely evade the recursion limit.
example above, it will likely evade the recursion limit, because the calls
that could have been indirect recursions are "consumed" when the
pending-callback counter saturates.

No. 2 may sound counter-intuitive. The :ref:`reason <why-circumvent>` is given
at the end of this page, for it is not very relevant to "normal", well-behaving
callbacks.
callbacks. Notice that this doesn't mean a smaller max-callback limit always
serves to curb runaway callback at runtime. It's not difficult to contrive a
counterexample.

In summary, these are the pros and cons of using large/small max-callback
bounds:

+--------------------------+------------------------+-----------------------+
| Callback behaviour | Small bound | Large bound |
+==========================+========================+=======================+
| Single-thread, plain | (No impact) |
| Single-thread, plain | (No impact) |
+--------------------------+------------------------+-----------------------+
| Single-thread, coroutine | ❌ May miss some purges| ✅ No extra issue |
+--------------------------+------------------------+-----------------------+
| Multi-thread, I/O bound | ✅ Better concurrency | ❌ High GIL contention|
+--------------------------+------------------------+-----------------------+
| Runaway recursion |Becomes endless loop| ✅ Stack limit works |
| Runaway callback |All bets are off; situation-dependent |
+--------------------------+------------------------+-----------------------+


Expand Down Expand Up @@ -159,7 +169,9 @@ In practice, the kind of "re-entering" prevented this way is almost always
caused by a key's :meth:`~object.__hash__` or :meth:`~object.__eq__`
*redirecting the control flow* in such a way that makes the :class:`LRUDict`
instance accessed again while already in the process of interacting with the
key.
key. This is typically the result of programming error, but possible reasons
include GIL-dropping in the implementation of these methods, which may arise
from complicated interaction with C-extensions.

The benefit to be gained from supporting full re-entrancy (beside raising
exceptions) seems minimal. If you know how to achieve this in a cost-efficient
Expand All @@ -168,8 +180,8 @@ manner, please help!

.. _why-circumvent:

Appendix: why max-callback limit circumvents stack limit
********************************************************
Appendix: why max-callback limit may circumvent stack limit
***********************************************************

(Implementation details inside)

Expand Down Expand Up @@ -198,7 +210,7 @@ loop::
callback [1]
insert
evict
purge [callback bypassed, too many pending]
purge [callback skipped, too many pending]
insert
.
.
Expand Down
13 changes: 6 additions & 7 deletions doc/source/thread-safety.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ The module relies on the :term:`global interpreter lock` (GIL) to maintain the
consistency of internal data structures. Internally, the building blocks are
mostly normal Python objects, although they're accessed and modified via the C
API. The GIL is expected to be held by the method caller, so that accesses are
sequentialized as a result of the GIL's functioning. When a thread is using a
method, it can be certain that no other thread is modifying the data at the
same time.
ordered as a result of the GIL's functioning. When a thread is using a method,
it can be certain that no other thread is modifying the data at the same time.

However, there are four places where the protection is not perfect. These are
the :ref:`callback <introduction:using a callback>`, the key's
Expand All @@ -35,10 +34,10 @@ example, when input/output (I/O) is performed. Some code may drop the GIL when
computing the hash by a C extension on an internal :term:`buffer <bytes-like
object>` for speed. Even if :meth:`~object.__hash__` may be made to execute
before entering the critical section (relying on not-so-public Python C API),
:meth:`~object.__eq__` currently cannot be. When a thread-switch happens as a
result of them in the middle of a method call, another thread may try to call
into a method, too, causing contention. There's limited built-in ability to
detect this at run-time, but currently,
:meth:`~object.__eq__` currently cannot be. When a thread-switch happens in the
middle of a method call, another thread may try to call into a method, too,
causing contention. There's limited built-in ability to detect this at
run-time, but currently,

.. warning::

Expand Down

0 comments on commit ef1d090

Please sign in to comment.