stdlib: Make __cxa_thread_atexit_impl lock-free (BZ 15686)

Message ID 20260730182656.1777259-1-adhemerval.zanella@linaro.org (mailing list archive)
State New
Headers
Series stdlib: Make __cxa_thread_atexit_impl lock-free (BZ 15686) |

Checks

Context Check Description
redhat-pt-bot/TryBot-apply_patch success Patch applied to master at the time it was sent
linaro-tcwg-bot/tcwg_glibc_build--master-arm success Build passed
redhat-pt-bot/TryBot-32bit success Build for i686
linaro-tcwg-bot/tcwg_glibc_build--master-aarch64 success Build passed
linaro-tcwg-bot/tcwg_glibc_check--master-arm success Test passed
linaro-tcwg-bot/tcwg_glibc_check--master-aarch64 success Test passed

Commit Message

Adhemerval Zanella Netto July 30, 2026, 6:26 p.m. UTC
  __cxa_thread_atexit_impl takes dl_load_lock to protect the
_dl_find_dso_for_object lookup and the l_tls_dtor_count increment
against a racing dlclose.  This deadlocks when the function is
reached from a thread spawned by an ELF constructor, because dlopen
runs constructors with dl_load_lock held.

Instead of releasing dl_load_lock around constructor execution
(which requires per-map serialization of the constructor calls and
re-auditing all the state accessed by dl_open_worker after the
initializers run), remove the lock acquisition by using
_dl_find_object instead.

The lock is not required for correctness, DSO_SYMBOL is the address
of the caller's __dso_handle, so the calling thread is executing
code of the object being looked up.  A dlclose that unloaded the
object concurrently would unmap the running code itself, which is
undefined regardless of the lock.

The per-thread dso_symbol_cache/lm_cache is also removed, the cache
key was never updated.

Using _dl_find_object also shows a slight better performance, it
replaces a O(n) by a lock-free O(log n) lookup.

This does not fully fix BZ 15686.  dlopen still runs ELF
constructors with dl_load_lock held, so a thread spawned by a
constructor that the constructor then joins still deadlocks if it
calls dlopen and related functions; or if it lazily binds to a
symbol defined in another dlopen'ed object (which reaches
add_dependency in elf/dl-lookup.c).  Only the TLS destructor
registration path is addressed here.

Checked on aarch64-linux-gnu, x86_64-linux-gnu, i686-linux-gnu, and
arm-linux-gnueabihf.
---
 stdlib/cxa_thread_atexit_impl.c  | 81 +++++++++++++++++---------------
 sysdeps/pthread/Makefile         |  6 +++
 sysdeps/pthread/tst-create2.c    | 52 ++++++++++++++++++++
 sysdeps/pthread/tst-create2mod.c | 51 ++++++++++++++++++++
 4 files changed, 152 insertions(+), 38 deletions(-)
 create mode 100644 sysdeps/pthread/tst-create2.c
 create mode 100644 sysdeps/pthread/tst-create2mod.c
  

Comments

Artem Proskurnev July 31, 2026, 1:51 p.m. UTC | #1
Hi!

I attached a minimal reproducible deadlock to BZ 15686.

https://sourceware.org/bugzilla/show_bug.cgi?id=15686

Unfortunately, this fix is not sufficient to resolve the issue.

You suggested trying it here:
https://inbox.sourceware.org/libc-alpha/f8138432-9ef3-41ec-a558-6f53011feefd@linaro.org/

I've done the testing:

https://inbox.sourceware.org/libc-alpha/b3fae3e9-e0d5-4e30-8ad6-08bcc8fbe86f@mail.ru/

30.07.2026 21:26, Adhemerval Zanella:
> __cxa_thread_atexit_impl takes dl_load_lock to protect the
> _dl_find_dso_for_object lookup and the l_tls_dtor_count increment
> against a racing dlclose.  This deadlocks when the function is
> reached from a thread spawned by an ELF constructor, because dlopen
> runs constructors with dl_load_lock held.
>
> Instead of releasing dl_load_lock around constructor execution
> (which requires per-map serialization of the constructor calls and
> re-auditing all the state accessed by dl_open_worker after the
> initializers run), remove the lock acquisition by using
> _dl_find_object instead.
>
> The lock is not required for correctness, DSO_SYMBOL is the address
> of the caller's __dso_handle, so the calling thread is executing
> code of the object being looked up.  A dlclose that unloaded the
> object concurrently would unmap the running code itself, which is
> undefined regardless of the lock.
>
> The per-thread dso_symbol_cache/lm_cache is also removed, the cache
> key was never updated.
>
> Using _dl_find_object also shows a slight better performance, it
> replaces a O(n) by a lock-free O(log n) lookup.
>
> This does not fully fix BZ 15686.  dlopen still runs ELF
> constructors with dl_load_lock held, so a thread spawned by a
> constructor that the constructor then joins still deadlocks if it
> calls dlopen and related functions; or if it lazily binds to a
> symbol defined in another dlopen'ed object (which reaches
> add_dependency in elf/dl-lookup.c).  Only the TLS destructor
> registration path is addressed here.
>
> Checked on aarch64-linux-gnu, x86_64-linux-gnu, i686-linux-gnu, and
> arm-linux-gnueabihf.
> ---
>   stdlib/cxa_thread_atexit_impl.c  | 81 +++++++++++++++++---------------
>   sysdeps/pthread/Makefile         |  6 +++
>   sysdeps/pthread/tst-create2.c    | 52 ++++++++++++++++++++
>   sysdeps/pthread/tst-create2mod.c | 51 ++++++++++++++++++++
>   4 files changed, 152 insertions(+), 38 deletions(-)
>   create mode 100644 sysdeps/pthread/tst-create2.c
>   create mode 100644 sysdeps/pthread/tst-create2mod.c
>
> diff --git a/stdlib/cxa_thread_atexit_impl.c b/stdlib/cxa_thread_atexit_impl.c
> index c4382bc9efe..c162461afb5 100644
> --- a/stdlib/cxa_thread_atexit_impl.c
> +++ b/stdlib/cxa_thread_atexit_impl.c
> @@ -25,15 +25,24 @@
>      combinations of all three functions are the link map list, a link map for a
>      DSO and the link map member l_tls_dtor_count.
>   
> -   __cxa_thread_atexit_impl acquires the dl_load_lock before accessing any
> -   shared state and hence multiple of its instances can safely execute
> -   concurrently.
> +   __cxa_thread_atexit_impl does not take dl_load_lock (taking it deadlocks
> +   if this function is reached from a thread spawned by an ELF constructor,
> +   because dlopen runs constructors with dl_load_lock held).  It locates the
> +   caller's link map with _dl_find_object, which is async-signal-safe and
> +   lock-free, and then increments l_tls_dtor_count atomically.
>   
> -   _dl_close_worker acquires the dl_load_lock before accessing any shared state
> -   as well and hence can concurrently execute multiple of its own instances as
> -   well as those of __cxa_thread_atexit_impl safely.  Not all accesses to
> -   l_tls_dtor_count are protected by the dl_load_lock, so we need to
> -   synchronize using atomics.
> +   Not taking the lock is safe because DSO_SYMBOL is the address of the
> +   caller's __dso_handle, so the calling thread is executing code of the very
> +   object.  A concurrent dlclose that unloads the object while this function
> +   runs would unmap the caller's code as well, which is undefined.  A
> +   conforming program must ensure, via its own synchronization, that the
> +   object stays loaded across this call, and that same synchronization
> +   publishes the l_tls_dtor_count increment to any subsequent
> +   _dl_close_worker.
> +
> +   _dl_close_worker acquires the dl_load_lock before accessing any shared
> +   state.  Not all accesses to l_tls_dtor_count are protected by the
> +   dl_load_lock, so we need to synchronize using atomics.
>   
>      __call_tls_dtors accesses the l_tls_dtor_count without taking the lock; it
>      decrements the value by one.  It does not need the big lock because it does
> @@ -63,7 +72,9 @@
>   
>      Concurrent executions of __call_tls_dtors should only ensure that the value
>      is accessed atomically; no reordering constraints need to be considered.
> -   Likewise for the increment of l_tls_dtor_count in __cxa_thread_atexit_impl.
> +   The same holds for the increment in __cxa_thread_atexit_impl, whose
> +   ordering against _dl_close_worker is provided by the caller as described
> +   above.
>   
>      There is still a possibility on concurrent execution of _dl_close_worker and
>      __call_tls_dtors where _dl_close_worker reads the value of l_tls_dtor_count
> @@ -72,6 +83,7 @@
>      is not very different from a case where __call_tls_dtors is called after
>      _dl_close_worker on the DSO and hence is an accepted execution.  */
>   
> +#include <dlfcn.h>
>   #include <stdio.h>
>   #include <stdlib.h>
>   #include <ldsodefs.h>
> @@ -88,8 +100,6 @@ struct dtor_list
>   };
>   
>   static __thread struct dtor_list *tls_dtor_list;
> -static __thread void *dso_symbol_cache;
> -static __thread struct link_map *lm_cache;
>   
>   /* Register a destructor for TLS variables declared with the 'thread_local'
>      keyword.  This function is only called from code generated by the C++
> @@ -102,42 +112,37 @@ __cxa_thread_atexit_impl (dtor_func func, void *obj, void *dso_symbol)
>   {
>     PTR_MANGLE (func);
>   
> -  /* Prepend.  */
>     struct dtor_list *new = calloc (1, sizeof (struct dtor_list));
>     if (__glibc_unlikely (new == NULL))
>       __libc_fatal ("Fatal glibc error: failed to register TLS destructor: "
>   		  "out of memory\n");
> +
> +  /* A concurrent dlclose may already have reset the link map of a matching
> +     entry, so check for it as well.  Either way the object is being unloaded
> +     from underneath the caller, which is undefined; assume the main program
> +     as for an unrecognized address.  */
> +  struct link_map *map;
> +  struct dl_find_object dfo;
> +  if (GLRO (dl_find_object) (dso_symbol, &dfo) == 0
> +      && dfo.dlfo_link_map != NULL)
> +    map = dfo.dlfo_link_map;
> +  else
> +    map = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
> +
>     new->func = func;
>     new->obj = obj;
> +  new->map = map;
> +
> +  /* This increment is only concurrently observed by the decrement in
> +     __call_tls_dtors and by the load in _dl_close_worker.  For the latter,
> +     the caller's own synchronization with dlclose provides the required
> +     ordering (see CONCURRENCY NOTES), so Relaxed MO is sufficient.  */
> +  atomic_fetch_add_relaxed (&map->l_tls_dtor_count, 1);
> +
> +  /* Prepend.  */
>     new->next = tls_dtor_list;
>     tls_dtor_list = new;
>   
> -  /* We have to acquire the big lock to prevent a racing dlclose from pulling
> -     our DSO from underneath us while we're setting up our destructor.  */
> -  __rtld_lock_lock_recursive (GL(dl_load_lock));
> -
> -  /* See if we already encountered the DSO.  */
> -  if (__glibc_unlikely (dso_symbol_cache != dso_symbol))
> -    {
> -      ElfW(Addr) caller = (ElfW(Addr)) dso_symbol;
> -
> -      struct link_map *l = _dl_find_dso_for_object (caller);
> -
> -      /* If the address is not recognized the call comes from the main
> -	 program (we hope).  */
> -      lm_cache = l ? l : GL(dl_ns)[LM_ID_BASE]._ns_loaded;
> -    }
> -
> -  /* This increment may only be concurrently observed either by the decrement
> -     in __call_tls_dtors since the other l_tls_dtor_count access in
> -     _dl_close_worker is protected by the dl_load_lock.  The execution in
> -     __call_tls_dtors does not really depend on this value beyond the fact that
> -     it should be atomic, so Relaxed MO should be sufficient.  */
> -  atomic_fetch_add_relaxed (&lm_cache->l_tls_dtor_count, 1);
> -  __rtld_lock_unlock_recursive (GL(dl_load_lock));
> -
> -  new->map = lm_cache;
> -
>     return 0;
>   }
>   
> diff --git a/sysdeps/pthread/Makefile b/sysdeps/pthread/Makefile
> index d0f3cd59ac6..5b062094ec4 100644
> --- a/sysdeps/pthread/Makefile
> +++ b/sysdeps/pthread/Makefile
> @@ -349,6 +349,7 @@ tests += \
>     tst-atfork3 \
>     tst-atfork4 \
>     tst-create1 \
> +  tst-create2 \
>     tst-fini1 \
>     tst-pt-tls4 \
>     # tests
> @@ -365,6 +366,7 @@ modules-names += \
>     tst-atfork3mod \
>     tst-atfork4mod \
>     tst-create1mod \
> +  tst-create2mod \
>     tst-fini1mod \
>     tst-stack2-mod \
>     tst-tls4moda \
> @@ -540,6 +542,10 @@ LDFLAGS-tst-create1 = -Wl,-export-dynamic
>   $(objpfx)tst-create1: $(shared-thread-library)
>   $(objpfx)tst-create1.out: $(objpfx)tst-create1mod.so
>   
> +$(objpfx)tst-create2: $(shared-thread-library)
> +$(objpfx)tst-create2mod.so: $(libsupport) $(shared-thread-library)
> +$(objpfx)tst-create2.out: $(objpfx)tst-create2mod.so
> +
>   $(objpfx)tst-stack2.out: $(objpfx)tst-stack2-mod.so
>   $(objpfx)tst-stack2-mod.so: $(shared-thread-library)
>   LDFLAGS-tst-stack2-mod.so = -Wl,-z,execstack
> diff --git a/sysdeps/pthread/tst-create2.c b/sysdeps/pthread/tst-create2.c
> new file mode 100644
> index 00000000000..b5c81dc64ff
> --- /dev/null
> +++ b/sysdeps/pthread/tst-create2.c
> @@ -0,0 +1,52 @@
> +/* Verify that a thread spawned by a dlopen constructor can register a
> +   TLS destructor without deadlocking (BZ 15686).
> +   Copyright (C) 2026 Free Software Foundation, Inc.
> +   This file is part of the GNU C Library.
> +
> +   The GNU C Library is free software; you can redistribute it and/or
> +   modify it under the terms of the GNU Lesser General Public
> +   License as published by the Free Software Foundation; either
> +   version 2.1 of the License, or (at your option) any later version.
> +
> +   The GNU C Library is distributed in the hope that it will be useful,
> +   but WITHOUT ANY WARRANTY; without even the implied warranty of
> +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> +   Lesser General Public License for more details.
> +
> +   You should have received a copy of the GNU Lesser General Public
> +   License along with the GNU C Library; if not, see
> +   <https://www.gnu.org/licenses/>.  */
> +
> +/* thread 1: dlopen -> ctor -> pthread_create (worker) -> pthread_join
> +   thread 2 (worker): __cxa_thread_atexit_impl -> lock (dl_load_lock)
> +
> +   dl_load_lock is held by thread 1 across the constructor execution, so if
> +   __cxa_thread_atexit_impl acquires it the worker thread blocks forever and
> +   pthread_join in the constructor never returns.  */
> +
> +#include <support/check.h>
> +#include <support/xdlfcn.h>
> +
> +static int
> +do_test (void)
> +{
> +  void *h = xdlopen ("tst-create2mod.so", RTLD_NOW);
> +
> +  /* The worker thread exited before the constructor's pthread_join
> +     returned, so its TLS destructor has already run.  */
> +  int *dtor_done = xdlsym (h, "tst_create2mod_dtor_done");
> +  TEST_COMPARE (*dtor_done, 1);
> +
> +  xdlclose (h);
> +
> +  /* The destructor already ran, so no reference is left on the module's
> +     l_tls_dtor_count and dlclose must have unloaded it.  */
> +  void *h2 = dlopen ("tst-create2mod.so", RTLD_NOW | RTLD_NOLOAD);
> +  TEST_VERIFY (h2 == NULL);
> +  if (h2 != NULL)
> +    xdlclose (h2);
> +
> +  return 0;
> +}
> +
> +#include <support/test-driver.c>
> diff --git a/sysdeps/pthread/tst-create2mod.c b/sysdeps/pthread/tst-create2mod.c
> new file mode 100644
> index 00000000000..3ec31ce15ea
> --- /dev/null
> +++ b/sysdeps/pthread/tst-create2mod.c
> @@ -0,0 +1,51 @@
> +/* Verify that a thread spawned by a dlopen constructor can register a
> +   TLS destructor without deadlocking (BZ 15686).
> +   Copyright (C) 2026 Free Software Foundation, Inc.
> +   This file is part of the GNU C Library.
> +
> +   The GNU C Library is free software; you can redistribute it and/or
> +   modify it under the terms of the GNU Lesser General Public
> +   License as published by the Free Software Foundation; either
> +   version 2.1 of the License, or (at your option) any later version.
> +
> +   The GNU C Library is distributed in the hope that it will be useful,
> +   but WITHOUT ANY WARRANTY; without even the implied warranty of
> +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> +   Lesser General Public License for more details.
> +
> +   You should have received a copy of the GNU Lesser General Public
> +   License along with the GNU C Library; if not, see
> +   <https://www.gnu.org/licenses/>.  */
> +
> +#include <stdlib.h>
> +#include <dso_handle.h>
> +#include <support/check.h>
> +#include <support/xthread.h>
> +
> +int tst_create2mod_dtor_done;
> +
> +static void
> +dtor (void *obj)
> +{
> +  *(int *) obj = 1;
> +}
> +
> +/* The module TLS access mirrors the real-world trigger (a C++ thread_local
> +   or Rust thread_local! first access), exercising __tls_get_addr from the
> +   spawned thread as well.  */
> +static __thread int tls_obj;
> +
> +static void *
> +worker (void *closure)
> +{
> +  tls_obj = 1;
> +  TEST_COMPARE (__cxa_thread_atexit_impl (dtor, &tst_create2mod_dtor_done,
> +					  __dso_handle), 0);
> +  return NULL;
> +}
> +
> +static void __attribute__ ((constructor))
> +do_init (void)
> +{
> +  xpthread_join (xpthread_create (NULL, worker, NULL));
> +}
  
Adhemerval Zanella Netto Aug. 3, 2026, 8:55 p.m. UTC | #2
Hi Artem,



Em sex., 31 de jul. de 2026 às 10:52, Artem Proskurnev <temap@mail.ru>
escreveu:

> Hi!
>
> I attached a minimal reproducible deadlock to BZ 15686.
>
> https://sourceware.org/bugzilla/show_bug.cgi?id=15686
>
> Unfortunately, this fix is not sufficient to resolve the issue.
>

Yes, I explicitly stated on the commit message this does not fully fix
BZ#15686.

It is a improvement to the the
__cxa_thread_atexit_impl scalability and can be reviewed and evaluated
separately.


> You suggested trying it here:
>
> https://inbox.sourceware.org/libc-alpha/f8138432-9ef3-41ec-a558-6f53011feefd@linaro.org/
>
> I've done the testing:
>
>
> https://inbox.sourceware.org/libc-alpha/b3fae3e9-e0d5-4e30-8ad6-08bcc8fbe86f@mail.ru/
>
> 30.07.2026 21:26, Adhemerval Zanella:
> > __cxa_thread_atexit_impl takes dl_load_lock to protect the
> > _dl_find_dso_for_object lookup and the l_tls_dtor_count increment
> > against a racing dlclose.  This deadlocks when the function is
> > reached from a thread spawned by an ELF constructor, because dlopen
> > runs constructors with dl_load_lock held.
> >
> > Instead of releasing dl_load_lock around constructor execution
> > (which requires per-map serialization of the constructor calls and
> > re-auditing all the state accessed by dl_open_worker after the
> > initializers run), remove the lock acquisition by using
> > _dl_find_object instead.
> >
> > The lock is not required for correctness, DSO_SYMBOL is the address
> > of the caller's __dso_handle, so the calling thread is executing
> > code of the object being looked up.  A dlclose that unloaded the
> > object concurrently would unmap the running code itself, which is
> > undefined regardless of the lock.
> >
> > The per-thread dso_symbol_cache/lm_cache is also removed, the cache
> > key was never updated.
> >
> > Using _dl_find_object also shows a slight better performance, it
> > replaces a O(n) by a lock-free O(log n) lookup.
> >
> > This does not fully fix BZ 15686.  dlopen still runs ELF
> > constructors with dl_load_lock held, so a thread spawned by a
> > constructor that the constructor then joins still deadlocks if it
> > calls dlopen and related functions; or if it lazily binds to a
> > symbol defined in another dlopen'ed object (which reaches
> > add_dependency in elf/dl-lookup.c).  Only the TLS destructor
> > registration path is addressed here.
> >
> > Checked on aarch64-linux-gnu, x86_64-linux-gnu, i686-linux-gnu, and
> > arm-linux-gnueabihf.
> > ---
> >   stdlib/cxa_thread_atexit_impl.c  | 81 +++++++++++++++++---------------
> >   sysdeps/pthread/Makefile         |  6 +++
> >   sysdeps/pthread/tst-create2.c    | 52 ++++++++++++++++++++
> >   sysdeps/pthread/tst-create2mod.c | 51 ++++++++++++++++++++
> >   4 files changed, 152 insertions(+), 38 deletions(-)
> >   create mode 100644 sysdeps/pthread/tst-create2.c
> >   create mode 100644 sysdeps/pthread/tst-create2mod.c
> >
> > diff --git a/stdlib/cxa_thread_atexit_impl.c
> b/stdlib/cxa_thread_atexit_impl.c
> > index c4382bc9efe..c162461afb5 100644
> > --- a/stdlib/cxa_thread_atexit_impl.c
> > +++ b/stdlib/cxa_thread_atexit_impl.c
> > @@ -25,15 +25,24 @@
> >      combinations of all three functions are the link map list, a link
> map for a
> >      DSO and the link map member l_tls_dtor_count.
> >
> > -   __cxa_thread_atexit_impl acquires the dl_load_lock before accessing
> any
> > -   shared state and hence multiple of its instances can safely execute
> > -   concurrently.
> > +   __cxa_thread_atexit_impl does not take dl_load_lock (taking it
> deadlocks
> > +   if this function is reached from a thread spawned by an ELF
> constructor,
> > +   because dlopen runs constructors with dl_load_lock held).  It
> locates the
> > +   caller's link map with _dl_find_object, which is async-signal-safe
> and
> > +   lock-free, and then increments l_tls_dtor_count atomically.
> >
> > -   _dl_close_worker acquires the dl_load_lock before accessing any
> shared state
> > -   as well and hence can concurrently execute multiple of its own
> instances as
> > -   well as those of __cxa_thread_atexit_impl safely.  Not all accesses
> to
> > -   l_tls_dtor_count are protected by the dl_load_lock, so we need to
> > -   synchronize using atomics.
> > +   Not taking the lock is safe because DSO_SYMBOL is the address of the
> > +   caller's __dso_handle, so the calling thread is executing code of
> the very
> > +   object.  A concurrent dlclose that unloads the object while this
> function
> > +   runs would unmap the caller's code as well, which is undefined.  A
> > +   conforming program must ensure, via its own synchronization, that the
> > +   object stays loaded across this call, and that same synchronization
> > +   publishes the l_tls_dtor_count increment to any subsequent
> > +   _dl_close_worker.
> > +
> > +   _dl_close_worker acquires the dl_load_lock before accessing any
> shared
> > +   state.  Not all accesses to l_tls_dtor_count are protected by the
> > +   dl_load_lock, so we need to synchronize using atomics.
> >
> >      __call_tls_dtors accesses the l_tls_dtor_count without taking the
> lock; it
> >      decrements the value by one.  It does not need the big lock because
> it does
> > @@ -63,7 +72,9 @@
> >
> >      Concurrent executions of __call_tls_dtors should only ensure that
> the value
> >      is accessed atomically; no reordering constraints need to be
> considered.
> > -   Likewise for the increment of l_tls_dtor_count in
> __cxa_thread_atexit_impl.
> > +   The same holds for the increment in __cxa_thread_atexit_impl, whose
> > +   ordering against _dl_close_worker is provided by the caller as
> described
> > +   above.
> >
> >      There is still a possibility on concurrent execution of
> _dl_close_worker and
> >      __call_tls_dtors where _dl_close_worker reads the value of
> l_tls_dtor_count
> > @@ -72,6 +83,7 @@
> >      is not very different from a case where __call_tls_dtors is called
> after
> >      _dl_close_worker on the DSO and hence is an accepted execution.  */
> >
> > +#include <dlfcn.h>
> >   #include <stdio.h>
> >   #include <stdlib.h>
> >   #include <ldsodefs.h>
> > @@ -88,8 +100,6 @@ struct dtor_list
> >   };
> >
> >   static __thread struct dtor_list *tls_dtor_list;
> > -static __thread void *dso_symbol_cache;
> > -static __thread struct link_map *lm_cache;
> >
> >   /* Register a destructor for TLS variables declared with the
> 'thread_local'
> >      keyword.  This function is only called from code generated by the
> C++
> > @@ -102,42 +112,37 @@ __cxa_thread_atexit_impl (dtor_func func, void
> *obj, void *dso_symbol)
> >   {
> >     PTR_MANGLE (func);
> >
> > -  /* Prepend.  */
> >     struct dtor_list *new = calloc (1, sizeof (struct dtor_list));
> >     if (__glibc_unlikely (new == NULL))
> >       __libc_fatal ("Fatal glibc error: failed to register TLS
> destructor: "
> >                 "out of memory\n");
> > +
> > +  /* A concurrent dlclose may already have reset the link map of a
> matching
> > +     entry, so check for it as well.  Either way the object is being
> unloaded
> > +     from underneath the caller, which is undefined; assume the main
> program
> > +     as for an unrecognized address.  */
> > +  struct link_map *map;
> > +  struct dl_find_object dfo;
> > +  if (GLRO (dl_find_object) (dso_symbol, &dfo) == 0
> > +      && dfo.dlfo_link_map != NULL)
> > +    map = dfo.dlfo_link_map;
> > +  else
> > +    map = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
> > +
> >     new->func = func;
> >     new->obj = obj;
> > +  new->map = map;
> > +
> > +  /* This increment is only concurrently observed by the decrement in
> > +     __call_tls_dtors and by the load in _dl_close_worker.  For the
> latter,
> > +     the caller's own synchronization with dlclose provides the required
> > +     ordering (see CONCURRENCY NOTES), so Relaxed MO is sufficient.  */
> > +  atomic_fetch_add_relaxed (&map->l_tls_dtor_count, 1);
> > +
> > +  /* Prepend.  */
> >     new->next = tls_dtor_list;
> >     tls_dtor_list = new;
> >
> > -  /* We have to acquire the big lock to prevent a racing dlclose from
> pulling
> > -     our DSO from underneath us while we're setting up our destructor.
> */
> > -  __rtld_lock_lock_recursive (GL(dl_load_lock));
> > -
> > -  /* See if we already encountered the DSO.  */
> > -  if (__glibc_unlikely (dso_symbol_cache != dso_symbol))
> > -    {
> > -      ElfW(Addr) caller = (ElfW(Addr)) dso_symbol;
> > -
> > -      struct link_map *l = _dl_find_dso_for_object (caller);
> > -
> > -      /* If the address is not recognized the call comes from the main
> > -      program (we hope).  */
> > -      lm_cache = l ? l : GL(dl_ns)[LM_ID_BASE]._ns_loaded;
> > -    }
> > -
> > -  /* This increment may only be concurrently observed either by the
> decrement
> > -     in __call_tls_dtors since the other l_tls_dtor_count access in
> > -     _dl_close_worker is protected by the dl_load_lock.  The execution
> in
> > -     __call_tls_dtors does not really depend on this value beyond the
> fact that
> > -     it should be atomic, so Relaxed MO should be sufficient.  */
> > -  atomic_fetch_add_relaxed (&lm_cache->l_tls_dtor_count, 1);
> > -  __rtld_lock_unlock_recursive (GL(dl_load_lock));
> > -
> > -  new->map = lm_cache;
> > -
> >     return 0;
> >   }
> >
> > diff --git a/sysdeps/pthread/Makefile b/sysdeps/pthread/Makefile
> > index d0f3cd59ac6..5b062094ec4 100644
> > --- a/sysdeps/pthread/Makefile
> > +++ b/sysdeps/pthread/Makefile
> > @@ -349,6 +349,7 @@ tests += \
> >     tst-atfork3 \
> >     tst-atfork4 \
> >     tst-create1 \
> > +  tst-create2 \
> >     tst-fini1 \
> >     tst-pt-tls4 \
> >     # tests
> > @@ -365,6 +366,7 @@ modules-names += \
> >     tst-atfork3mod \
> >     tst-atfork4mod \
> >     tst-create1mod \
> > +  tst-create2mod \
> >     tst-fini1mod \
> >     tst-stack2-mod \
> >     tst-tls4moda \
> > @@ -540,6 +542,10 @@ LDFLAGS-tst-create1 = -Wl,-export-dynamic
> >   $(objpfx)tst-create1: $(shared-thread-library)
> >   $(objpfx)tst-create1.out: $(objpfx)tst-create1mod.so
> >
> > +$(objpfx)tst-create2: $(shared-thread-library)
> > +$(objpfx)tst-create2mod.so: $(libsupport) $(shared-thread-library)
> > +$(objpfx)tst-create2.out: $(objpfx)tst-create2mod.so
> > +
> >   $(objpfx)tst-stack2.out: $(objpfx)tst-stack2-mod.so
> >   $(objpfx)tst-stack2-mod.so: $(shared-thread-library)
> >   LDFLAGS-tst-stack2-mod.so = -Wl,-z,execstack
> > diff --git a/sysdeps/pthread/tst-create2.c
> b/sysdeps/pthread/tst-create2.c
> > new file mode 100644
> > index 00000000000..b5c81dc64ff
> > --- /dev/null
> > +++ b/sysdeps/pthread/tst-create2.c
> > @@ -0,0 +1,52 @@
> > +/* Verify that a thread spawned by a dlopen constructor can register a
> > +   TLS destructor without deadlocking (BZ 15686).
> > +   Copyright (C) 2026 Free Software Foundation, Inc.
> > +   This file is part of the GNU C Library.
> > +
> > +   The GNU C Library is free software; you can redistribute it and/or
> > +   modify it under the terms of the GNU Lesser General Public
> > +   License as published by the Free Software Foundation; either
> > +   version 2.1 of the License, or (at your option) any later version.
> > +
> > +   The GNU C Library is distributed in the hope that it will be useful,
> > +   but WITHOUT ANY WARRANTY; without even the implied warranty of
> > +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> > +   Lesser General Public License for more details.
> > +
> > +   You should have received a copy of the GNU Lesser General Public
> > +   License along with the GNU C Library; if not, see
> > +   <https://www.gnu.org/licenses/>.  */
> > +
> > +/* thread 1: dlopen -> ctor -> pthread_create (worker) -> pthread_join
> > +   thread 2 (worker): __cxa_thread_atexit_impl -> lock (dl_load_lock)
> > +
> > +   dl_load_lock is held by thread 1 across the constructor execution,
> so if
> > +   __cxa_thread_atexit_impl acquires it the worker thread blocks
> forever and
> > +   pthread_join in the constructor never returns.  */
> > +
> > +#include <support/check.h>
> > +#include <support/xdlfcn.h>
> > +
> > +static int
> > +do_test (void)
> > +{
> > +  void *h = xdlopen ("tst-create2mod.so", RTLD_NOW);
> > +
> > +  /* The worker thread exited before the constructor's pthread_join
> > +     returned, so its TLS destructor has already run.  */
> > +  int *dtor_done = xdlsym (h, "tst_create2mod_dtor_done");
> > +  TEST_COMPARE (*dtor_done, 1);
> > +
> > +  xdlclose (h);
> > +
> > +  /* The destructor already ran, so no reference is left on the module's
> > +     l_tls_dtor_count and dlclose must have unloaded it.  */
> > +  void *h2 = dlopen ("tst-create2mod.so", RTLD_NOW | RTLD_NOLOAD);
> > +  TEST_VERIFY (h2 == NULL);
> > +  if (h2 != NULL)
> > +    xdlclose (h2);
> > +
> > +  return 0;
> > +}
> > +
> > +#include <support/test-driver.c>
> > diff --git a/sysdeps/pthread/tst-create2mod.c
> b/sysdeps/pthread/tst-create2mod.c
> > new file mode 100644
> > index 00000000000..3ec31ce15ea
> > --- /dev/null
> > +++ b/sysdeps/pthread/tst-create2mod.c
> > @@ -0,0 +1,51 @@
> > +/* Verify that a thread spawned by a dlopen constructor can register a
> > +   TLS destructor without deadlocking (BZ 15686).
> > +   Copyright (C) 2026 Free Software Foundation, Inc.
> > +   This file is part of the GNU C Library.
> > +
> > +   The GNU C Library is free software; you can redistribute it and/or
> > +   modify it under the terms of the GNU Lesser General Public
> > +   License as published by the Free Software Foundation; either
> > +   version 2.1 of the License, or (at your option) any later version.
> > +
> > +   The GNU C Library is distributed in the hope that it will be useful,
> > +   but WITHOUT ANY WARRANTY; without even the implied warranty of
> > +   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
> > +   Lesser General Public License for more details.
> > +
> > +   You should have received a copy of the GNU Lesser General Public
> > +   License along with the GNU C Library; if not, see
> > +   <https://www.gnu.org/licenses/>.  */
> > +
> > +#include <stdlib.h>
> > +#include <dso_handle.h>
> > +#include <support/check.h>
> > +#include <support/xthread.h>
> > +
> > +int tst_create2mod_dtor_done;
> > +
> > +static void
> > +dtor (void *obj)
> > +{
> > +  *(int *) obj = 1;
> > +}
> > +
> > +/* The module TLS access mirrors the real-world trigger (a C++
> thread_local
> > +   or Rust thread_local! first access), exercising __tls_get_addr from
> the
> > +   spawned thread as well.  */
> > +static __thread int tls_obj;
> > +
> > +static void *
> > +worker (void *closure)
> > +{
> > +  tls_obj = 1;
> > +  TEST_COMPARE (__cxa_thread_atexit_impl (dtor,
> &tst_create2mod_dtor_done,
> > +                                       __dso_handle), 0);
> > +  return NULL;
> > +}
> > +
> > +static void __attribute__ ((constructor))
> > +do_init (void)
> > +{
> > +  xpthread_join (xpthread_create (NULL, worker, NULL));
> > +}
>
  

Patch

diff --git a/stdlib/cxa_thread_atexit_impl.c b/stdlib/cxa_thread_atexit_impl.c
index c4382bc9efe..c162461afb5 100644
--- a/stdlib/cxa_thread_atexit_impl.c
+++ b/stdlib/cxa_thread_atexit_impl.c
@@ -25,15 +25,24 @@ 
    combinations of all three functions are the link map list, a link map for a
    DSO and the link map member l_tls_dtor_count.
 
-   __cxa_thread_atexit_impl acquires the dl_load_lock before accessing any
-   shared state and hence multiple of its instances can safely execute
-   concurrently.
+   __cxa_thread_atexit_impl does not take dl_load_lock (taking it deadlocks
+   if this function is reached from a thread spawned by an ELF constructor,
+   because dlopen runs constructors with dl_load_lock held).  It locates the
+   caller's link map with _dl_find_object, which is async-signal-safe and
+   lock-free, and then increments l_tls_dtor_count atomically.
 
-   _dl_close_worker acquires the dl_load_lock before accessing any shared state
-   as well and hence can concurrently execute multiple of its own instances as
-   well as those of __cxa_thread_atexit_impl safely.  Not all accesses to
-   l_tls_dtor_count are protected by the dl_load_lock, so we need to
-   synchronize using atomics.
+   Not taking the lock is safe because DSO_SYMBOL is the address of the
+   caller's __dso_handle, so the calling thread is executing code of the very
+   object.  A concurrent dlclose that unloads the object while this function
+   runs would unmap the caller's code as well, which is undefined.  A
+   conforming program must ensure, via its own synchronization, that the
+   object stays loaded across this call, and that same synchronization
+   publishes the l_tls_dtor_count increment to any subsequent
+   _dl_close_worker.
+
+   _dl_close_worker acquires the dl_load_lock before accessing any shared
+   state.  Not all accesses to l_tls_dtor_count are protected by the
+   dl_load_lock, so we need to synchronize using atomics.
 
    __call_tls_dtors accesses the l_tls_dtor_count without taking the lock; it
    decrements the value by one.  It does not need the big lock because it does
@@ -63,7 +72,9 @@ 
 
    Concurrent executions of __call_tls_dtors should only ensure that the value
    is accessed atomically; no reordering constraints need to be considered.
-   Likewise for the increment of l_tls_dtor_count in __cxa_thread_atexit_impl.
+   The same holds for the increment in __cxa_thread_atexit_impl, whose
+   ordering against _dl_close_worker is provided by the caller as described
+   above.
 
    There is still a possibility on concurrent execution of _dl_close_worker and
    __call_tls_dtors where _dl_close_worker reads the value of l_tls_dtor_count
@@ -72,6 +83,7 @@ 
    is not very different from a case where __call_tls_dtors is called after
    _dl_close_worker on the DSO and hence is an accepted execution.  */
 
+#include <dlfcn.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <ldsodefs.h>
@@ -88,8 +100,6 @@  struct dtor_list
 };
 
 static __thread struct dtor_list *tls_dtor_list;
-static __thread void *dso_symbol_cache;
-static __thread struct link_map *lm_cache;
 
 /* Register a destructor for TLS variables declared with the 'thread_local'
    keyword.  This function is only called from code generated by the C++
@@ -102,42 +112,37 @@  __cxa_thread_atexit_impl (dtor_func func, void *obj, void *dso_symbol)
 {
   PTR_MANGLE (func);
 
-  /* Prepend.  */
   struct dtor_list *new = calloc (1, sizeof (struct dtor_list));
   if (__glibc_unlikely (new == NULL))
     __libc_fatal ("Fatal glibc error: failed to register TLS destructor: "
 		  "out of memory\n");
+
+  /* A concurrent dlclose may already have reset the link map of a matching
+     entry, so check for it as well.  Either way the object is being unloaded
+     from underneath the caller, which is undefined; assume the main program
+     as for an unrecognized address.  */
+  struct link_map *map;
+  struct dl_find_object dfo;
+  if (GLRO (dl_find_object) (dso_symbol, &dfo) == 0
+      && dfo.dlfo_link_map != NULL)
+    map = dfo.dlfo_link_map;
+  else
+    map = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
+
   new->func = func;
   new->obj = obj;
+  new->map = map;
+
+  /* This increment is only concurrently observed by the decrement in
+     __call_tls_dtors and by the load in _dl_close_worker.  For the latter,
+     the caller's own synchronization with dlclose provides the required
+     ordering (see CONCURRENCY NOTES), so Relaxed MO is sufficient.  */
+  atomic_fetch_add_relaxed (&map->l_tls_dtor_count, 1);
+
+  /* Prepend.  */
   new->next = tls_dtor_list;
   tls_dtor_list = new;
 
-  /* We have to acquire the big lock to prevent a racing dlclose from pulling
-     our DSO from underneath us while we're setting up our destructor.  */
-  __rtld_lock_lock_recursive (GL(dl_load_lock));
-
-  /* See if we already encountered the DSO.  */
-  if (__glibc_unlikely (dso_symbol_cache != dso_symbol))
-    {
-      ElfW(Addr) caller = (ElfW(Addr)) dso_symbol;
-
-      struct link_map *l = _dl_find_dso_for_object (caller);
-
-      /* If the address is not recognized the call comes from the main
-	 program (we hope).  */
-      lm_cache = l ? l : GL(dl_ns)[LM_ID_BASE]._ns_loaded;
-    }
-
-  /* This increment may only be concurrently observed either by the decrement
-     in __call_tls_dtors since the other l_tls_dtor_count access in
-     _dl_close_worker is protected by the dl_load_lock.  The execution in
-     __call_tls_dtors does not really depend on this value beyond the fact that
-     it should be atomic, so Relaxed MO should be sufficient.  */
-  atomic_fetch_add_relaxed (&lm_cache->l_tls_dtor_count, 1);
-  __rtld_lock_unlock_recursive (GL(dl_load_lock));
-
-  new->map = lm_cache;
-
   return 0;
 }
 
diff --git a/sysdeps/pthread/Makefile b/sysdeps/pthread/Makefile
index d0f3cd59ac6..5b062094ec4 100644
--- a/sysdeps/pthread/Makefile
+++ b/sysdeps/pthread/Makefile
@@ -349,6 +349,7 @@  tests += \
   tst-atfork3 \
   tst-atfork4 \
   tst-create1 \
+  tst-create2 \
   tst-fini1 \
   tst-pt-tls4 \
   # tests
@@ -365,6 +366,7 @@  modules-names += \
   tst-atfork3mod \
   tst-atfork4mod \
   tst-create1mod \
+  tst-create2mod \
   tst-fini1mod \
   tst-stack2-mod \
   tst-tls4moda \
@@ -540,6 +542,10 @@  LDFLAGS-tst-create1 = -Wl,-export-dynamic
 $(objpfx)tst-create1: $(shared-thread-library)
 $(objpfx)tst-create1.out: $(objpfx)tst-create1mod.so
 
+$(objpfx)tst-create2: $(shared-thread-library)
+$(objpfx)tst-create2mod.so: $(libsupport) $(shared-thread-library)
+$(objpfx)tst-create2.out: $(objpfx)tst-create2mod.so
+
 $(objpfx)tst-stack2.out: $(objpfx)tst-stack2-mod.so
 $(objpfx)tst-stack2-mod.so: $(shared-thread-library)
 LDFLAGS-tst-stack2-mod.so = -Wl,-z,execstack
diff --git a/sysdeps/pthread/tst-create2.c b/sysdeps/pthread/tst-create2.c
new file mode 100644
index 00000000000..b5c81dc64ff
--- /dev/null
+++ b/sysdeps/pthread/tst-create2.c
@@ -0,0 +1,52 @@ 
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor without deadlocking (BZ 15686).
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+/* thread 1: dlopen -> ctor -> pthread_create (worker) -> pthread_join
+   thread 2 (worker): __cxa_thread_atexit_impl -> lock (dl_load_lock)
+
+   dl_load_lock is held by thread 1 across the constructor execution, so if
+   __cxa_thread_atexit_impl acquires it the worker thread blocks forever and
+   pthread_join in the constructor never returns.  */
+
+#include <support/check.h>
+#include <support/xdlfcn.h>
+
+static int
+do_test (void)
+{
+  void *h = xdlopen ("tst-create2mod.so", RTLD_NOW);
+
+  /* The worker thread exited before the constructor's pthread_join
+     returned, so its TLS destructor has already run.  */
+  int *dtor_done = xdlsym (h, "tst_create2mod_dtor_done");
+  TEST_COMPARE (*dtor_done, 1);
+
+  xdlclose (h);
+
+  /* The destructor already ran, so no reference is left on the module's
+     l_tls_dtor_count and dlclose must have unloaded it.  */
+  void *h2 = dlopen ("tst-create2mod.so", RTLD_NOW | RTLD_NOLOAD);
+  TEST_VERIFY (h2 == NULL);
+  if (h2 != NULL)
+    xdlclose (h2);
+
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/sysdeps/pthread/tst-create2mod.c b/sysdeps/pthread/tst-create2mod.c
new file mode 100644
index 00000000000..3ec31ce15ea
--- /dev/null
+++ b/sysdeps/pthread/tst-create2mod.c
@@ -0,0 +1,51 @@ 
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor without deadlocking (BZ 15686).
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <stdlib.h>
+#include <dso_handle.h>
+#include <support/check.h>
+#include <support/xthread.h>
+
+int tst_create2mod_dtor_done;
+
+static void
+dtor (void *obj)
+{
+  *(int *) obj = 1;
+}
+
+/* The module TLS access mirrors the real-world trigger (a C++ thread_local
+   or Rust thread_local! first access), exercising __tls_get_addr from the
+   spawned thread as well.  */
+static __thread int tls_obj;
+
+static void *
+worker (void *closure)
+{
+  tls_obj = 1;
+  TEST_COMPARE (__cxa_thread_atexit_impl (dtor, &tst_create2mod_dtor_done,
+					  __dso_handle), 0);
+  return NULL;
+}
+
+static void __attribute__ ((constructor))
+do_init (void)
+{
+  xpthread_join (xpthread_create (NULL, worker, NULL));
+}