12

How can I interrupt a blocking Queue.get() in Python 3.X?

In Python 2.X setting a long timeout seems to work but the same cannot be said for Python 3.5.

Running on Windows 7, CPython 3.5.1, 64 bit both machine and Python. Seems like it does not behave the same on Ubuntu.

Community
  • 1
  • 1
Bharel
  • 23,672
  • 5
  • 40
  • 80

3 Answers3

15

The reason it works on Python 2 is that Queue.get with a timeout on Python 2 is implemented incredibly poorly, as a polling loop with increasing sleeps between non-blocking attempts to acquire the underlying lock; Python 2 doesn't actually feature a lock primitive that supports a timed blocking acquire (which is what a Queue internal Condition variable needs, but lacks, so it uses the busy loop). When you're trying this on Python 2, all you're checking is whether the Ctrl-C is processed after one of the (short) time.sleep calls finishes, and the longest sleep in Condition is only 0.05 seconds, which is so short you probably wouldn't notice even if you hit Ctrl-C the instant a new sleep started.

Python 3 has true timed lock acquire support (thanks to narrowing the number of target OSes to those which feature a native timed mutex or semaphore of some sort). As such, you're actually blocking on the lock acquisition for the whole timeout period, not blocking for 0.05s at a time between polling attempts.

It looks like Windows allows for registering handlers for Ctrl-C that mean that Ctrl-C doesn't necessarily generate a true signal, so the lock acquisition isn't interrupted to handle it. Python is informed of the Ctrl-C when the timed lock acquisition eventually fails, so if the timeout is short, you'll eventually see the KeyboardInterrupt, but it won't be seen until the timeout lapses. Since Python 2 Condition is only sleeping 0.05 seconds at a time (or less) the Ctrl-C is always processed quickly, but Python 3 will sleep until the lock is acquired.

Ctrl-Break is guaranteed to behave as a signal, but it also can't be handled by Python properly (it just kills the process) which probably isn't what you want either.

If you want Ctrl-C to work, you're stuck polling to some extent, but at least (unlike Python 2) you can effectively poll for Ctrl-C while live blocking on the queue the rest of the time (so you're alerted to an item becoming free immediately, which is the common case).

import time
import queue

def get_timed_interruptable(q, timeout):
    stoploop = time.monotonic() + timeout - 1
    while time.monotonic() < stoploop:
        try:
            return q.get(timeout=1)  # Allow check for Ctrl-C every second
        except queue.Empty:
            pass
    # Final wait for last fraction of a second
    return q.get(timeout=max(0, stoploop + 1 - time.monotonic()))                

This blocks for a second at a time until:

  1. The time remaining is less than a second (it blocks for the remaining time, then allows the Empty to propagate normally)
  2. Ctrl-C was pressed during the one second interval (after the remainder of that second elapses, KeyboardInterrupt is raised)
  3. An item is acquired (if Ctrl-C was pressed, it will raise at this point too)
ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • Two notes - first the one second timeout on the `queue.get()` call will produce a noticeable delay to the user, I recommend something shorter. Second, maintaining both `stoptime` and `remaining` violates [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) - simply use `while time.monotonic() < stoptime` and eliminate `remaining`. – Steve Cohen May 04 '16 at 23:43
  • @SteveCohen: I've adjusted to eliminate the additional variable (and reduce the per loop arithmetic); I can't use `time.monotonic() < stoptime` directly because I want the loop to stop when there is less than a second remaining. As for the delay; eh. A one second delay cancelling the program is rarely meaningful; if the Ctrl-C is to cancel a task but continue running and must be responsive, the timeout can be tweaked, but I'm optimizing for the common case; usually, Ctrl-C is rare (cost never paid), blocking is semi-common (more loops multiply cost of common case). – ShadowRanger May 05 '16 at 00:00
  • Of course you still have the problem that the `queue.get()` is not executed if the timeout is less than 1. That is truly awful, as the `queue.get()` should always happen at least once. If you use a much shorter timeout, you could ignore the (trivial) overrun case and simplify the whole shebang. As you mentioned in your response, Python 2 polled at 0.05 seconds (ish). – Steve Cohen May 05 '16 at 00:09
  • 1
    @SteveCohen: It executes in the final `get` (though I had to fix the calculation to ensure it doesn't go below 0 in race conditions, `Queue.get` doesn't treat timeout of < 0 as 0 as I thought) even when running less than a second, only the one in the loop that ignores `queue.Empty` gets skipped. And a 0.05s polling loop is too short; I've had issues in work projects that assumed many threads in a producer consumer chain (each thread feeding the next) could use timed blocking waits, but the end result was massive GIL contention in Py2; they'd use 1.4-1.8 cores, and do 0.6-0.8 cores of work. – ShadowRanger May 05 '16 at 00:35
  • To be clear, even before the bug fix, except in narrow race conditions, a `get` would occur even for sub-1 second timeouts. You didn't actually read the code before calling this "truly awful" – ShadowRanger May 05 '16 at 00:37
  • Truly my bad on that one - I had reviewed the edits, etc. and just missed the duplicate `queue.get()` on the second return line after the while loop. As you point out, all of the versions would have worked as expected. I have added another answer below for another take on this but without the duplicated queue.get(), time calls, multiple returns, etc. I would have simply put it here, but you can only say so much in a comment. – Steve Cohen May 05 '16 at 05:15
  • POSIX version works fine ([blocking C calls *can* be interrtuped](http://stackoverflow.com/a/33652496/4279)). Windows often has an incompatible API but it is almost always possible to do anything that can be done on Linux (e.g., even if there is no [`prctl()` (that allows to kill a child process if the parent dies)](http://stackoverflow.com/a/19448096/4279) on Windows but there are [job objects that allow to implement a similar thing](http://stackoverflow.com/q/23434842/4279), and therefore It is probably a bug in Python 3 that `Queue().get()` is not interrupted. – jfs May 09 '16 at 15:00
4

As mentioned in the comment thread to the great answer @ShadowRanger provided above, here is an alternate simplified form of his function:

import queue


def get_timed_interruptable(in_queue, timeout):
    '''                                                                         
    Perform a queue.get() with a short timeout to avoid                         
    blocking SIGINT on Windows.                                                 
    '''
    while True:
        try:
            # Allow check for Ctrl-C every second                               
            return in_queue.get(timeout=min(1, timeout))
        except queue.Empty:
            if timeout < 1:
                raise
            else:
                timeout -= 1

And as @Bharel pointed out in the comments, this could run a few milliseconds longer than the absolute timeout, which may be undesirable. As such here is a version with significantly better precision:

import time
import queue


def get_timed_interruptable_precise(in_queue, timeout):
    '''                                                                         
    Perform a queue.get() with a short timeout to avoid                         
    blocking SIGINT on Windows.  Track the time closely
    for high precision on the timeout.                                                 
    '''
    timeout += time.monotonic()
    while True:
        try:
            # Allow check for Ctrl-C every second                               
            return in_queue.get(timeout=max(0, min(1, timeout - time.monotonic())))
        except queue.Empty:
            if time.monotonic() > timeout:
                raise
ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
Steve Cohen
  • 722
  • 4
  • 6
  • 1
    While I really like your answer, keep in mind you lose the accuracy in here over time. You sleep for 1 second but the part that decreases the seconds, and catches the exception also takes time and you don't take it into account. In @ShadowRanger's answer, timing out for 5.1234 seconds will be 5.1234 + ~0.00002, in your case it can even get to 5.1236. Might not matter in the usual case but who knows. – Bharel May 05 '16 at 05:27
  • @Bharel: Thanks for the feedback. I considered that issue and I agree - this is far less precise. For small timeouts, however, the difference will be negligible. If ultimate precision is required (and I am dying to hear why it might be) then the calls to `time.monotonic()` can be added back while maintaining the single 'return' / 'queue.get()' structure. I have added that as an edit. – Steve Cohen May 05 '16 at 05:57
  • You don't need to go to extreme in order to find a situation where it applies. Let's say you have a server running for days and after 3 days if there is no answer, you wish to shut down. You get the time difference between the current time and 12:00 AM in 3 days. In his case you'll sleep on the queue for exactly 3 days (+ 0.00001 seconds) in your case, who knows. In this case, sleeping on a queue is actually a good way to implement it. Your design is cleaner though so I prefer it. Just change it to monotonic and it's awesome :-) – Bharel May 05 '16 at 06:08
  • @SteveCohen: You had a narrow race condition (hard to hit/test for on Windows where the timer granularity is low, but possible) that could cause you to raise a `ValueError` due to a slightly negative value being passed to `get` as the `timeout`. [Mentioned fixing the same thing](https://stackoverflow.com/questions/36817050/interrupting-a-queue-get/37016663#comment61630431_37016663) in my own answer, figured I'd make yours match. Feel free to tweak design; nested `max`/`min` is a little ugly, but kept changes minimal; alternative would be to calculate/store next timeout in `except` block. – ShadowRanger Dec 02 '19 at 19:16
-3

Just use get_nowait which won't block.

import time
...

while True:
  if not q.empty():
    q.get_nowait()
    break
  time.sleep(1) # optional timeout

This is obviously busy waiting, but q.get() does basically the same thing.

E_net4
  • 27,810
  • 13
  • 101
  • 139
EzPizza
  • 979
  • 1
  • 13
  • 22