Skip to content

Thread spuriously marked dead after interrupting a join call #90882

Closed

Description

@KevinShweh
BPO 46726
Nosy @tim-one, @pitrou, @vstinner, @serhiy-storchaka, @eryksun, @bensimner, @snoopjedi

Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.

Show more details

GitHub fields:

assignee = None
closed_at = None
created_at = <Date 2022-02-11.23:47:13.610>
labels = ['type-bug', 'library', '3.9', '3.10', '3.11']
title = 'Thread spuriously marked dead after interrupting a join call'
updated_at = <Date 2022-02-14.02:08:58.437>
user = 'https://bugs.python.org/KevinShweh'

bugs.python.org fields:

activity = <Date 2022-02-14.02:08:58.437>
actor = 'eryksun'
assignee = 'none'
closed = False
closed_date = None
closer = None
components = ['Library (Lib)']
creation = <Date 2022-02-11.23:47:13.610>
creator = 'Kevin Shweh'
dependencies = []
files = []
hgrepos = []
issue_num = 46726
keywords = []
message_count = 17.0
messages = ['413106', '413107', '413108', '413109', '413110', '413118', '413140', '413146', '413155', '413162', '413164', '413166', '413174', '413191', '413202', '413203', '413208']
nosy_count = 8.0
nosy_names = ['tim.peters', 'pitrou', 'vstinner', 'serhiy.storchaka', 'eryksun', 'Kevin Shweh', 'bjs', 'SnoopJeDi']
pr_nums = []
priority = 'normal'
resolution = None
stage = None
status = 'open'
superseder = None
type = 'behavior'
url = 'https://bugs.python.org/issue46726'
versions = ['Python 3.9', 'Python 3.10', 'Python 3.11']

Activity

  1. KevinShweh commented on Feb 11, 2022

    KevinShwehmannequin
    MannequinAuthor

    This code in Thread._wait_for_tstate_lock:

    try:
        if lock.acquire(block, timeout):
            lock.release()
            self._stop()
    except:
        if lock.locked():
            # bpo-45274: lock.acquire() acquired the lock, but the function
            # was interrupted with an exception before reaching the
            # lock.release(). It can happen if a signal handler raises an
            # exception, like CTRL+C which raises KeyboardInterrupt.
            lock.release()
            self._stop()
        raise
    

    has a bug. The "if lock.locked()" check doesn't check whether this code managed to acquire the lock. It checks if *anyone at all* is holding the lock. The lock is almost always locked, so this code will perform a spurious call to self._stop() if it gets interrupted while trying to acquire the lock.

    Thread.join uses this method to wait for a thread to finish, so a thread will spuriously be marked dead if you interrupt a join call with Ctrl-C while it's trying to acquire the lock. Here's a reproducer:

    import time
    import threading
     
    event = threading.Event()
     
    def target():
        event.wait()
        print('thread done')
     
    t = threading.Thread(target=target)
    t.start()
    print('joining now')
    try:
        t.join()
    except KeyboardInterrupt:
        pass
    print(t.is_alive())
    event.set()

    Interrupt this code with Ctrl-C during the join(), and print(t.is_alive()) will print False.

  2. added
    3.11only security fixes
    stdlibStandard Library Python modules in the Lib/ directory
    type-bugAn unexpected behavior, bug, or error
    on Feb 11, 2022
  3. bensimner commented on Feb 12, 2022

    bensimnermannequin
    Mannequin

    This is a duplicate of https://bugs.python.org/issue45274
    but the patch there did not fix it

    I've just added a PR there (or should it go here?) that (i think) fixes this.

    The issue is that the lock.locked() call just checks that *someone* has the lock, not that the previous acquire() is what got the lock.
    If it's just that the tstate lock is held because the thread is still running, then it's premature to release() the lock.

  4. KevinShweh commented on Feb 12, 2022

    KevinShwehmannequin
    MannequinAuthor

    bpo-45274 was a subtly different issue. That was a problem that happened if the thread got interrupted *between* the acquire and the release, causing it to *not* release the lock and *not* perform end-of-thread cleanup.

    The fix for that issue caused this issue, which happens if the thread gets interrupted *during* the acquire, in which case it *does* release the lock (that someone else is holding) and *does* perform end-of-thread cleanup even though it's not supposed to do either of those things.

  5. KevinShweh commented on Feb 12, 2022

    KevinShwehmannequin
    MannequinAuthor

    The PR you submitted doesn't work, unfortunately. It essentially reintroduces bpo-45274. If this line:

        if locked := lock.acquire(block, timeout):

    gets interrupted between the acquire and the assignment, locked is still False. That's rare, but so is an interruption between the acquire and the release, which is the original form of bpo-45274.

  6. bensimner commented on Feb 12, 2022

    bensimnermannequin
    Mannequin

    You are right,

    What one really needs here is a way to know *who* owns the lock,
    but threading.Lock does not provide that.

    The race on := is much smaller than the original race and I suspect in practice will be very hard to hit.

    As the original bpo notes, it may not be possible to write a complete fix for this in pure Python, after all there may always be another interrupt between the except and the second attempted release.

    The issue with the solution was that it turned a relatively hard-to-hit race condition into a really easy-to-hit one, but perhaps the outcome is slightly less worse?

  7. 22 remaining items

  8. abrahammurciano commented on May 21, 2024

    @abrahammurciano

    In the 3.13 branch the implementation of Thread.join is just a wrapper around _thread._ThreadHandle.join, which is implemented in C.

    https://github.com/python/cpython/blob/3.13/Lib/threading.py#L1090

    self._handle.join(timeout)

    Does this mean this issue will be fixed by this change in python3.13?

  9. fofoni commented on May 22, 2024

    @fofoni
    Contributor

    Looks like it's really fixed! Just tested the reproducer in the OP:

    $ PYENV_VERSION=3.6 python threading-bug.py  # also same result in 3.7, 3.8, and 3.13.0b1
    joining now
    ^CTrue
    thread done
    $
    

    and:

    $ PYENV_VERSION=3.9 python threading-bug.py  # also same result in 3.10, 3.11, and 3.12
    joining now
    ^CFalse
    $
    

    (Note that this bug was really introduced only in 3.9.)

  10. vstinner commented on Mar 5, 2025

    @vstinner
    Member

    I close the issue, it has been fixed in Python 3.13.

    Looks like it's really fixed!

    I confirm, I also see "True" (alive) when I interrupt the reproducer with CTRL+C on Python 3.13 and 3.14.

    Python 3.12 has still the issue, but I consider that the bug is a minor issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.10 (EOL)end of life3.11only security fixes3.9 (EOL)end of lifestdlibStandard Library Python modules in the Lib/ directorytype-bugAn unexpected behavior, bug, or error

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions