macOS v3: #110 switches back to `MI_TLS_MODEL=PTHREADS` and brings back the exit-time crash (Mode B of #71)

未关闭
#116 0 条评论 0 个 reaction 已指派 1 人 在 GitHub 查看

维护者通常 1 天内回复

@shulaoda 已经在做这个了。

开始于 2026年10月29日。

评估

这个 Issue 还没有评估数据。

描述

Summary

#110 (libmimalloc-sys2 0.1.64 / mimalloc-safe 0.1.68) changed the TLS model for v3 on macOS from MI_TLS_MODEL_LOCAL + MI_TLS_RECURSE_GUARD to MI_TLS_MODEL=PTHREADS. This brings back the bug that #71 and #82 fixed. It is Mode B in #71 and Problem 2 in microsoft/mimalloc#1301.

At exit(), mi_process_done deletes the default-theap pthread key on the main thread. Other threads are still running at that point, so any thread that allocates afterwards loses its theap.

With v3.6.0 the symptoms are:

  • Debug builds: SIGABRT from mi_assert_internal(theap==NULL) in _mi_thread_init_with_heap. Nothing is printed to stderr.
  • Release builds: no crash in my runs. However, every allocation after that point creates a new theap. In the probe below, allocations got about 500× slower and RSS grew by about 350 MB within 100 ms.

I found this in rolldown. After the bump to 0.1.68 (rolldown/rolldown#11297), the macOS node test job fails on most runs: the rolldown CLI finishes the build and is then killed with SIGABRT. Example: https://github.com/rolldown/rolldown/actions/runs/40803444105/job/123222619759

Reproduction

The repro does not involve rolldown or Node.js. Background threads keep allocating while main returns.

Cargo.toml:

[package]
name = "mi-repro"
version = "0.1.0"
edition = "2021"

[dependencies]
mimalloc-safe = { version = "=0.1.68", features = ["skip_collect_on_exit", "v3"] }

src/main.rs:

// Background threads keep allocating while the main thread returns from
// `main`, i.e. calls `exit()`, which runs mimalloc's `mi_process_done`.
#[global_allocator]
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;

fn main() {
  for _ in 0..4 {
    std::thread::spawn(|| loop {
      let v: Vec<u8> = Vec::with_capacity(64);
      std::hint::black_box(&v);
    });
  }
  std::thread::sleep(std::time::Duration::from_millis(2));
}
cargo build
for i in $(seq 500); do ./target/debug/mi-repro >/dev/null 2>&1 || echo "exit $?"; done | sort | uniq -c
#   14 exit 134      <- 0.1.68; no output with 0.1.67
Crash report (debug, 0.1.68)
Thread 0 (main):
  _pthread_mutex_firstfit_lock_slow
  ftrylockfile / tryflush / _fwalk
  exit
  dyld start

Thread 4 (crashed, SIGABRT):
  __pthread_kill
  pthread_kill
  abort
  _mi_assert_fail
  _mi_thread_init_with_heap + 248     // "theap==NULL", src/heap/init.c:358
  _mi_thread_init
  mi_theap_init
  mi_malloc_generic_admin
  mi_malloc_generic_fallback
  _mi_malloc_generic
  _mi_theap_malloc_zero
  ...
  mi_malloc_aligned
  <mimalloc_safe::MiMalloc as GlobalAlloc>::alloc

Results

The two changes in 0.1.64, the mimalloc version and the TLS model, can be separated with a [patch.crates-io] copy of libmimalloc-sys2 that has only the macOS TLS lines in build.rs swapped. Each cell below is 500 debug runs of the repro above.

LOCAL + RECURSE_GUARD PTHREADS
mimalloc v3.5.2 (sys2 0.1.63) 0 (stock 0.1.67) 26 × SIGABRT
mimalloc v3.6.0 (sys2 0.1.64) 0 14 × SIGABRT (stock 0.1.68)

The TLS model decides the outcome. The mimalloc version does not. Release builds of all four variants had no abnormal exits in 500 runs.

In rolldown, I ran the CLI 1800 times (12 parallel loops × 50 runs × 3 rounds):

rolldown build aborts
commit before the bump 0/1800
the bump commit 44/1800
current main with only mimalloc-safe/libmimalloc-sys2 pinned back to 0.1.67/0.1.63 0/1800

Root cause

0.1.64 bundles upstream v3.6.0 (b18ab4ee) unmodified, so all line links below point to upstream.

  1. The main thread calls exit(). mimalloc's destructor runs mi_process_done → mi_process_unwind_once → _mi_tls_slots_done().
  2. In the PTHREADS model, _mi_tls_slots_done sets the global _mi_theap_default_key to MI_PTHREAD_KEY_INVALID and calls pthread_key_delete. This affects every thread at once.
  3. exit() runs its cleanup chain before it terminates the other threads, so they keep running. In rolldown these are rayon and tokio workers.
  4. _mi_theap_default() reads that global key. Once the key is invalid it returns NULL on every thread, so the next malloc takes the thread-init path.
  5. mi_heap_check_for_existing_theap finds the theap that this thread still owns, and the debug assertion theap==NULL fails.
  6. In release builds, a new tld and theap are created. However, _mi_theap_default_set cannot store the new theap while the key is invalid, and _mi_tls_slots_init is a do_once that will not recreate the key. As a result, every later allocation on that thread repeats the init.
  7. The assertion message never shows up because os_preloading = true is set at the end of process done, which stops mimalloc's output.

With LOCAL, _mi_theap_default() reads a per-thread __thread variable and _mi_tls_slots_done() does nothing. A live thread keeps its theap after process done. Under RECURSE_GUARD, the assertion in step 5 is also compiled out.

Upstream dev3 still calls _mi_tls_slots_done() unconditionally on unix (src/heap/process.c, checked 2026-10-29), and v3.5.2 behaves the same way. PTHREADS is also upstream's default TLS model on Apple, OpenBSD, and Android.

Release impact

Probe: call mi_process_done() by hand while 4 workers keep allocating (release)
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;

#[global_allocator]
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;

unsafe extern "C" {
  fn mi_process_done();
}

static COUNT: AtomicU64 = AtomicU64::new(0);

fn rss_kb() -> u64 {
  let out = std::process::Command::new("ps")
    .args(["-o", "rss=", "-p", &std::process::id().to_string()])
    .output()
    .unwrap();
  String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0)
}

fn rate(window: Duration) -> u64 {
  let start = COUNT.load(Ordering::Relaxed);
  std::thread::sleep(window);
  (COUNT.load(Ordering::Relaxed) - start) * 1000 / window.as_millis() as u64
}

fn main() {
  for _ in 0..4 {
    std::thread::spawn(|| loop {
      let v: Vec<u8> = Vec::with_capacity(64);
      std::hint::black_box(&v);
      drop(v);
      COUNT.fetch_add(1, Ordering::Relaxed);
    });
  }
  std::thread::sleep(Duration::from_millis(50));
  let before_rate = rate(Duration::from_millis(100));
  let before_rss = rss_kb();
  unsafe { mi_process_done() };
  let after_rate = rate(Duration::from_millis(100));
  let after_rss = rss_kb();
  println!("allocs/s before={before_rate} after={after_rate}  rss_kb before={before_rss} after={after_rss}");
  std::process::exit(0);
}
allocs/s before → after RSS before → after (100 ms)
0.1.67 (LOCAL) ~31M → ~31M 2.2 MB → 2.3 MB
0.1.68 (PTHREADS) ~64M → ~0.13M 2.3 MB → ~355 MB

The exit window is usually short, so release users are mostly saved by timing. If threads stay busy during a long exit, for example a process.exit() in the middle of a build, they leak a theap on every allocation until _exit.

Possible fixes

#110 moved to PTHREADS because v3.6.0 with LOCAL hits the cached-theap refcount assertion during thread teardown. A fix should keep that case working.

  1. Keep PTHREADS and do not delete the TLS keys while other threads are still registered. I tried this patch against v3.6.0:

    --- a/src/heap/init.c
    +++ b/src/heap/init.c
    @@ -676,7 +676,12 @@ static void mi_process_unwind_once(void)
         }
       }
    
    -  _mi_tls_slots_done();
    +  // Other threads may still be running (and allocating) while the process exits.
    +  // With pthread keys, deleting the default theap key makes all of them lose their theap,
    +  // so only release the TLS slots when no other thread is registered.
    +  if (mi_atomic_load_acquire(&_mi_subproc_main()->thread_count) <= 1) {
    +    _mi_tls_slots_done();
    +  }
     }
    

    Results with the repro and probe above:

    • debug: 0/500 aborts (unpatched 0.1.68 had 21/500 in the same session);
    • release probe: no change after mi_process_done (~50–60M allocs/s, RSS stays at 2.3 MB).

    Open question: with live threads, the keys are now leaked at exit. That costs nothing at process exit, but a dlclose with live threads would leave _mi_theap_cached_key's destructor pointing into the unloaded image. This probably belongs upstream in microsoft/mimalloc, since PTHREADS is the default model there.

  2. Go back to LOCAL + RECURSE_GUARD on macOS and fix the thread-teardown assertion that motivated #110. My repro passes on v3.6.0 + LOCAL (0/500). However, it never exits a thread, so it does not cover the case #110 was about.

Downstream workaround for now: pin mimalloc-safe = "=0.1.67" and libmimalloc-sys2 = "=0.1.63".

Environment

  • macOS 26.7.1 (25G241), arm64
  • Apple clang 21.0.0, rustc 1.99.0
  • mimalloc-safe 0.1.68 / libmimalloc-sys2 0.1.64 (mimalloc v3.6.0 b18ab4ee), features v3 and skip_collect_on_exit (same as rolldown's binding)
主要语言
Rust
星标
26
派生
5
平均合并
1 天 44 分钟
30 天内合并 PR
12

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

napi-rs/mimalloc-safe 的其他 Issue

查看 napi-rs/mimalloc-safe 的全部 Issue

相似的 Issue

更多 Rust Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。