Skip to content

fix: agent - Fix proc-events high CPU on proc-info eviction - #11895

Open
yinjiping wants to merge 15 commits into
v6.6from
task_lifetime
Open

fix: agent - Fix proc-events high CPU on proc-info eviction#11895
yinjiping wants to merge 15 commits into
v6.6from
task_lifetime

Conversation

@yinjiping

@yinjiping yinjiping commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

When a process exited or executed a new image, proc-events removed its symbolizer_proc_info from the hash and waited for all outstanding references to be released:

AO_DEC(&p->use);

while (AO_GET(&p->use) != 0)
    CLIB_PAUSE();

free_proc_cache(p);

If a Java symbol task or another reader failed to release its reference, p->use never reached zero. The proc-events thread then spun indefinitely, consuming an entire CPU core and preventing subsequent exec/exit events from being processed.

Java symbol task                 proc-events
       │                              │
       ├─ p->use++                    │
       ├─ waits for symbol refresh    │
       ├─ fails to release reference  │
       │                              ├─ removes p from hash
       │                              ├─ drops the base reference
       │                              └─ while (p->use != 0)
       │                                      │
       │                                      └─ spins forever

1. Make Java Symbol Refresh Bounded and Wakeable

The Java symbol refresh path was updated so it can no longer retain a proc-info reference indefinitely.

Changes include:

  • Replaced the unbounded pthread_cond_wait() with pthread_cond_timedwait().
  • Added a 10-second refresh timeout:
#define JAVA_SYMS_REFRESH_TIMEOUT_SECS 10
  • On timeout, clear need_refresh, set the update status to failure, and return so the caller can release p->use.
  • Protect setting, checking, clearing, waiting, and signaling need_refresh with the same task->mutex to prevent lost wakeups.
  • When the collector exits, mark the task as stopped and broadcast the condition variable so all waiters wake immediately.

2. Protect Java Symbol Task Lifetime

The Java task itself could previously be freed while another thread was still accessing it.

The task now contains:

ref_count
stopped

The creator, worker, and refresh waiter hold their own references. The task is destroyed only after the final reference is released.

Additional thread-pool fixes include:

  • Roll back queued tasks when realloc() or pthread_create() fails.
  • Correctly check pthread_create() using ret != 0.
  • Avoid publishing invalid worker slots.
  • Retain the task while collector setup is still using it.
  • Prevent dangling queue entries.
  • Prevent map/log sockets from being closed twice.

Relevant commits:

df24172 fix: agent - Fix proc-events high CPU on proc-info eviction
8cdc452 fix: synchronize Java symbol refresh requests
9abed00 fix: handle pthread_create errors correctly
5bada36 fix: retain Java symbol task during collector setup

3. Protect hash_search → use++ Reference Acquisition

A separate UAF window existed between finding a proc-info pointer and incrementing its reference count:

reader                         writer/proc-events
  │                                    │
  ├─ hash_search returns p             │
  ├─ descheduled                       │
  │                                    ├─ removes p from hash
  │                                    ├─ use becomes zero
  │                                    └─ frees p
  │
  └─ AO_INC(&p->use)  ← UAF

The lookup path now uses cache-line-separated per-thread reader slots:

reader slot active++
    -> hash_search(pid)
    -> p->use++
reader slot active--

Writers serialize hash search/delete operations and wait for all active reference-acquisition sections to finish after removing an entry:

writer lock
    -> hash search
    -> hash delete
writer unlock
    -> synchronize_proc_cache_ref_acquirers()
    -> transfer or release the hash-owned reference

This protects only the short hash_search → use++ window. The caller’s later access remains protected by p->use.

Commit:

33b2fbe fix: protect proc cache reference acquisition

4. Remove Busy-Waiting from Proc-Info Eviction

The fundamental high-CPU fix was to stop waiting synchronously for p->use to reach zero.

The old behavior:

while (AO_GET(&p->use) != 0)
    CLIB_PAUSE();

was replaced with deferred reclamation:

if (AO_SUB_F(&p->use, 1) == 0)
    free_proc_cache(p);
else
    add p to retired_proc_caches;

The proc-events thread now continues processing other events immediately.

A reaper periodically scans the retired list:

use == 0: remove and free
use > 0:  leave in the list and check again later

Expensive symbol-cache destruction is performed after releasing the retired-list lock.

reader/Java task                 proc-events
       │                              │
       ├─ holds p->use                ├─ removes p from hash
       │                              ├─ drops the base reference
       │                              ├─ sees use > 0
       │                              ├─ adds p to retired list
       │                              └─ continues processing events
       │
       └─ p->use--                    later reaper
                                          ├─ sees use == 0
                                          └─ frees p

Even if a future reference is retained indefinitely, proc-events will no longer spin or block.

Commit:

8e2cf07 fix: defer proc cache reclamation

5. Record Why a Reference Was Acquired

A use_reason marker was added to symbolizer_proc_info to help diagnose long-lived references.

The currently recorded reasons are:

PROC_USE_INC_REASON_UNKNOWN
PROC_USE_INC_REASON_HASH_QUERY
PROC_USE_INC_REASON_JAVA_TAST

The marker records the most recent important reference-increment reason. It does not maintain a separate reference count for each reason.

Commit:

5a05cd2 feat: record proc cache reference reason

6. Add Retired Proc-Cache Diagnostics

The retired list now has:

  • A dedicated mutex.
  • A monotonic retirement timestamp.
  • Current retired-object count.
  • Oldest waiting time.
  • Accounted memory estimates.
  • Process start time and symbol-cache state.

The following command was added:

deepflow-ebpfctl proc-cache-reclaim show --older-than 60

It reports summary fields such as:

active_count
retired_count
total_count
total_limit
usage
admission_paused
rejected_total
reclaimed_total
oldest_wait
overdue_count
matched_count
returned_count
truncated

Per-process details include:

PID
COMM
USE
WAIT(s)
START_TIME
MEMORY
SYMCACHE
LAST_INC_REASON

At most 1,024 detail records are returned per request, while summary counters still cover the complete retired list.

Commit:

de07b62 feat: expose proc cache reclamation diagnostics

Remote execution support was added separately in deepflow-core:

c2408af feat: expose proc cache diagnostics via remote exec

7. Bound Proc-Cache Memory Growth

Deferred reclamation prevents high CPU, but a permanently retained reference could still cause retired objects to accumulate. A configurable total object limit was therefore added:

inputs:
  ebpf:
    tunning:
      proc_cache_max_entries: 65536

Configuration:

Minimum:  1,024
Default: 65,536
Maximum: 262,144
Unit:     entries

The limit covers every allocated but not yet freed proc-cache object:

total_count =
    RESERVED
  + ACTIVE
  + IN_FLIGHT
  + RETIRED

A capacity slot is reserved before allocation using CAS. It is returned on every initialization failure or after final destruction in free_proc_cache().

When the limit is reached:

  • New proc-cache creation is rejected.
  • Existing lookups continue.
  • Hash deletion continues.
  • Ring processing continues.
  • Retired-object reclamation continues.
  • Target processes are unaffected.
  • Creation resumes automatically after the total count falls below the limit.

The limit applies on Agent startup and supports runtime hot updates.

Commits:

43496df feat: bound proc cache memory growth
f12aadd docs: clarify proc cache capacity accounting

8. Preserve Ownership When Ring Enqueue Fails

After a proc-info entry leaves the hash, ownership is normally transferred to proc_event_ring.

Previously, if event allocation or ring enqueue failed, the object could be left without an owner:

not in hash
not in ring
not in retired list

The failure path now retains ownership locally:

if (add_proc_ev_info_to_ring(type, &kv) != 0 &&
    kv.v.proc_info_p != 0)
    free_symbolizer_cache_kvp(&kv);

The object is then either:

  • Freed immediately when use == 0, or
  • Added to the retired list when references remain.

This prevents leaks of the proc-info object, its symbol cache, and its capacity slot.

9. Add Capacity Metrics and Rate-Limited Logging

Runtime metrics include:

proc_cache_active_count
proc_cache_retired_count
proc_cache_total_count
proc_cache_total_limit
proc_cache_rejected_total
proc_cache_reclaimed_total
proc_cache_oldest_wait_secs

Capacity logging behavior is:

First time the limit is reached:       immediate WARNING
Continued capacity pressure:           at most one WARNING every two hours
Capacity recovery:                     immediate INFO
Limit reached again after recovery:    immediate WARNING

The timestamps use CLOCK_MONOTONIC, so system clock adjustments do not affect rate limiting.

Repeated warnings are event-driven: if no creation, release, or configuration event occurs, only the initial warning is guaranteed.

Performance Impact

The capacity work does not add a global capacity lock, capacity CAS, retired-list lock, or logging check to normal proc-info hash lookups.

New operations occur only during:

  • Proc-cache creation.
  • Creation failure rollback.
  • Final object destruction.
  • Configuration updates.
  • Diagnostics and metrics collection.

The lookup path retains only the short per-thread reader-slot protection needed to make hash_search → use++ safe.

Final Protection Model

The final implementation provides three layers of protection:

Layer 1: Bounded Java refresh and safe task lifetime
         Prevent references from being retained indefinitely.

Layer 2: Safe reference acquisition and deferred reclamation
         Prevent UAF and ensure proc-events never busy-waits.

Layer 3: Global proc-cache object limit
         Prevent unbounded memory growth even if a reference is permanently leaked.

The resulting behavior guarantees that:

  • proc-events no longer spins while waiting for p->use.
  • The hash_search → use++ window cannot access freed memory.
  • Java refresh waiters cannot block forever or access a freed task.
  • Referenced objects are safely moved to the retired list.
  • Long-lived references are observable through wait time and reason.
  • Ring failures do not lose object ownership.
  • Total proc-cache object growth is bounded.
  • Normal proc-info lookup remains free of new global capacity locks.

Branches involved

  • v6.6 (fix branch on the authoritative host); merge into later releases as planned.

Checklist

  • Dependency update needed (no)
  • Common problem (bounded wait / reference counting reusable for other tasks in the same pool)
  • Compiles (gcc -fsyntax-only SYNTAX_OK)
  • Unit tests pass (to run on the authoritative host per the verification plan)

This PR is for:

@yinjiping yinjiping changed the title fix: agent - Fix high CPU caused by proc-info cache eviction spinning… fix: agent - Fix proc-events high CPU on proc-info eviction Sep 7, 2026
@yinjiping
yinjiping force-pushed the task_lifetime branch 3 times, most recently from 09ff4a4 to 5bada36 Compare September 8, 2026 03:41
@kylewanginchina

Copy link
Copy Markdown
Contributor

@claude review一下当前PR中的代码修改

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 3m 19s —— View job


I'll analyze this and get back to you.

@kylewanginchina

Copy link
Copy Markdown
Contributor

必须修改的问题

1. 修复 worker 获取错误 thread_index 的并发问题

位置:

  • worker_thread()
  • thread_pool_add_task()

当前通过共享变量传递新 worker 的索引:

pool->thread_index = pool->thread_count;
pthread_create(&thread, NULL, &worker_thread, pool);

worker 启动后再读取:

int thread_idx = pool->thread_index;

如果短时间连续创建两个 worker,第一个 worker 可能在线程启动较晚时读到第二个 worker 的 index,随后因为线程 ID 不匹配而退出:

if (pool->threads[thread_idx].thread != thread)
    pthread_exit(NULL);

可能造成:

  • thread_count 仍包含已经退出的 worker;
  • Java symbol task 永久留在 pending queue;
  • collector 永远不启动;
  • task/socket/refcount 长期不释放。

必须修改的原因

这是实际的任务永久卡住风险,而且当前 MR 正在修改同一段线程池创建逻辑。若不处理,MR 虽然修复了一类永久等待,但仍可能通过另一条路径制造永久 pending。

建议修改

不要使用共享的 pool->thread_index 传递 index。可以:

  • 给每个 worker 传独立的 {pool, index} 启动参数;或
  • worker 获取 pool->lock 后,通过 pthread_equal() 查找自己的 slot。

2. 修正 pthread_cond_timedwait() 的超时判断和 deadline

位置:

update_java_symbol_file()

当前实现:

while (task->need_refresh) {
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    ts.tv_sec += JAVA_SYMS_REFRESH_TIMEOUT_SECS;

    refresh_rc =
        pthread_cond_timedwait(&task->cond, &task->mutex, &ts);

    if (refresh_rc == ETIMEDOUT) {
        task->need_refresh = false;
        task->update_status = -1;
        break;
    }
}

这里有两个相关问题,应当合并成一个 Review 意见。

2.1 timeout 后没有重新检查 predicate

刷新完成和 timeout 可能同时发生。collector 可能已经执行:

task->update_status = 0;
task->need_refresh = false;
pthread_cond_signal(&task->cond);

pthread_cond_timedwait() 仍可能在边界条件下返回 ETIMEDOUT。当前代码会直接把成功结果覆盖成:

task->update_status = -1;

应该先检查:

if (!task->need_refresh)
    break;

只有 predicate 仍然为 true,才认定刷新超时。

2.2 deadline 不应在循环内重新计算

当前每次 spurious wakeup 都会重新获得 10 秒等待时间,因此不能严格保证整个调用最多等待 10 秒。

deadline 应在进入循环前计算一次,后续重复使用。

建议结构

struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += JAVA_SYMS_REFRESH_TIMEOUT_SECS;

while (task->need_refresh && !task->stopped) {
    int rc = pthread_cond_timedwait(
        &task->cond, &task->mutex, &deadline);

    /* 先检查业务条件,处理 signal/timeout 边界竞争。 */
    if (!task->need_refresh || task->stopped)
        break;

    if (rc != 0) {
        task->need_refresh = false;
        task->update_status = -1;
        break;
    }
}

更理想的是使用 CLOCK_MONOTONIC,但如果本 MR 希望控制范围,至少必须做到:

  • deadline 只计算一次;
  • timedwait 返回后先检查 predicate;
  • 处理所有非零返回值。

必须修改的原因

“保证 Java symbol update 在有限时间内返回”是本 MR 的核心目标。如果 deadline 可以反复延长,或者成功结果会被 timeout 覆盖,说明核心等待逻辑还不完整。


合入前置条件

3. Rebase 最新 v6.6 并正确解决冲突

当前 PR 与 v6.6 存在冲突,相关 verify checks 也因此没有运行。

最新 v6.6 中已经存在大量 Java attach preflight 逻辑,而 MR 当前 head 中没有这些代码。解决冲突时必须保留:

  • JVM 类型和版本检查;
  • Java 8 安全版本限制;
  • DisableAttachMechanism 检查;
  • PID reuse 检查;
  • namespace/version helper;
  • preflight timeout 等逻辑。

这不需要写成一条具体的代码 Review comment,但它是合入的必要前提。冲突解决后需要重新看最终 diff。


不作为本 MR 阻塞项的问题

下面这些可以不要求当前 MR 修改:

proc-events 最多仍可能忙等 10 秒

while (AO_GET(&p->use) != 0)
    CLIB_PAUSE();

它确实意味着异常时仍可能短时间高 CPU,但本 MR 的核心价值是把“永久忙等”变成“有界等待”。如果团队接受这一行为,可以后续单独优化,不阻塞本 MR。

pthread_detach() 使用 errno 打印错误

这是错误日志准确性问题:

strerror(errno)

应该使用 pthread_detach() 的返回值,但不是核心稳定性问题,可以顺手改,也可以后续处理。

refcount assert、mutex/cond destroy

属于健壮性和代码规范优化,不是当前已确认的功能阻塞项。

正常刷新使用 broadcast

当前 Java symbol update 是单线程消费,通常只有一个 waiter,因此不要求本 MR 必须从 signal 改成 broadcast

@yinjiping

yinjiping commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

总结:proc-events 高 CPU 修复及后续加固

一、原始问题

进程退出或 exec 后,proc-events 会从 Proc Cache hash 中删除 p,然后释放 hash 持有的基础引用:

AO_DEC(&p->use);

while (AO_GET(&p->use) != 0)
    CLIB_PAUSE();

free_proc_cache(p);

如果 Java symbol task 或其他查询长期没有归还引用:

p->use 永远大于 0

proc-events 就会一直执行忙等:

while + CLIB_PAUSE

结果是:

  • proc-events 长期占用一个 CPU。
  • 后续 exec/exit 事件不能处理。
  • Proc Cache 无法释放。
  • 可能进一步积累内存。

原始时序:

Java symbol task             proc-events
       │                          │
       ├─ p->use++                │
       ├─ 等待 symbol refresh     │
       ├─ 异常后没有归还引用      │
       │                          ├─ 从 hash 删除 p
       │                          ├─ 基础引用 use--
       │                          └─ while (use != 0)
       │                                  │
       │                                  └─ 永久忙等,CPU 100%

二、修复 Java symbol 引用长期不归还

相关提交:

df24172 fix: agent - Fix proc-events high CPU on proc-info eviction
8cdc452 fix: synchronize Java symbol refresh requests
9abed00 fix: handle pthread_create errors correctly
5bada36 fix: retain Java symbol task during collector setup

主要修改位于 jvm_symbol_collect.c

1. Java refresh 等待增加 10 秒超时

原来的无限等待:

pthread_cond_wait();

改成:

pthread_cond_timedwait();

最大等待:

#define JAVA_SYMS_REFRESH_TIMEOUT_SECS 10

超时后:

  • 清除 need_refresh
  • 设置更新失败状态。
  • 返回调用者。
  • Java symbol 线程能够执行 p->use--

2. 修复 condition variable 丢失唤醒

以下操作统一由 task->mutex 保护:

设置 need_refresh
检查 need_refresh
清除 need_refresh
等待 condition
发送 condition signal

避免请求线程刚设置 refresh、还没进入等待时,collector 已经完成 signal,导致请求线程永久睡眠。

3. collector 退出时唤醒等待者

collector 异常退出时设置:

task->stopped = true;
task->need_refresh = false;
task->update_status = -1;
pthread_cond_broadcast(&task->cond);

等待线程能够立即退出,不必永久等待已经消失的 collector。

4. Java task 增加引用计数

symbol_collect_task_t 增加:

ref_count
stopped

creator、worker 和 refresh waiter 分别持有引用,最后一个引用释放时才销毁 task,避免:

worker 释放 task
refresh waiter 继续访问 task
        ↓
       UAF

5. 修复线程池失败路径

补充处理:

  • realloc() 失败后撤销已入队任务。
  • 正确判断 pthread_create() 返回值是 != 0,不是 < 0
  • pthread_create() 失败后回滚队列计数。
  • 避免任务留在队列中但已经被释放。
  • 避免 map/log socket 被重复关闭。
  • collector 初始化期间由 creator 引用保护 task。

三、修复 hash_search → use++ 之间的 UAF

提交:

33b2fbe fix: protect proc cache reference acquisition

原来的竞态:

reader                       writer/proc-events
  │                                  │
  ├─ hash_search 得到 p              │
  ├─ 被调度出去                      │
  │                                  ├─ 从 hash 删除 p
  │                                  ├─ use -> 0
  │                                  └─ free(p)
  │
  └─ p->use++  ← p 已释放,UAF

修复方式是在 find_proc_info_and_get_ref() 中使用 cache-line 分离的 per-thread reader slot:

reader slot active++
    -> hash_search
    -> p->use++
reader slot active--

writer 执行:

writer lock
    -> hash search
    -> hash delete
writer unlock
    -> synchronize_proc_cache_ref_acquirers()
    -> 转交或释放 hash 基础引用

这样 writer 不会在 reader 已经取得指针、但还没增加引用时释放 p


四、从根本上取消 proc-events 忙等

提交:

8e2cf07 fix: defer proc cache reclamation

这是防止 proc-events 高 CPU 的根本兜底。

free_symbolizer_cache_kvp() 不再执行:

while (p->use != 0)
    CLIB_PAUSE();

现在改成:

if (AO_SUB_F(&p->use, 1) == 0)
    free_proc_cache(p);
else
     p 加入 retired_proc_caches;

后续 reap_retired_proc_caches() 周期扫描:

use == 0:摘除并释放
use > 0:跳过,下一次再检查

新时序:

Java/reader                  proc-events
    │                            │
    ├─ 持有 p->use               ├─ 从 hash 删除 p
    │                            ├─ 释放基础引用
    │                            ├─ 发现 use > 0
    │                            ├─ 放入 retired list
    │                            └─ 继续处理其他事件,不忙等
    │
    └─ p->use--                  后续 reaper
                                     ├─ 发现 use == 0
                                     └─ free_proc_cache(p)

即使以后再次发生引用长期不归还:

  • proc-events 也不会占满 CPU。
  • 只会有对象暂时停留在 retired list。
  • 其他进程事件继续正常处理。

五、记录长期引用原因

提交:

5a05cd2 feat: record proc cache reference reason

symbolizer_proc_info 中增加原子原因标记:

PROC_USE_INC_REASON_UNKNOWN
PROC_USE_INC_REASON_HASH_QUERY
PROC_USE_INC_REASON_JAVA_TAST

重点记录两个可能长期持有引用的来源:

  • Hash 查询。
  • Java symbol task。

它记录的是“最近一次重要的引用增加原因”,便于判断 retired 对象为什么一直不能释放。


六、增加 retired list 诊断能力

提交:

de07b62 feat: expose proc cache reclamation diagnostics

增加:

  • retired_proc_caches_lock 保护链表。
  • retired_at_ns 记录首次进入 retired list 的单调时间。
  • 等待时间、启动时间和基础内存统计。
  • deepflow-ebpfctl 诊断命令。

命令:

deepflow-ebpfctl proc-cache-reclaim show --older-than 60

输出包括:

PID
COMM
USE
WAIT(s)
START_TIME
MEMORY
SYMCACHE
LAST_INC_REASON

以及 active、retired、超时数量和内存汇总。


七、增加 Proc Cache 总量上限

提交:

43496df feat: bound proc cache memory growth
f12aadd docs: clarify proc cache capacity accounting

如果某个引用永久不归还,retired list 虽然不会造成高 CPU,但仍可能持续占用内存。因此增加:

proc_cache_max_entries: 65536

范围:

1,024~262,144

统一限制:

total_count =
    初始化中
  + hash active
  + proc-event ring 中转
  + retired 等待释放

创建前预留容量名额,最终 free_proc_cache() 后归还。

达到上限后:

  • 拒绝创建新的 Proc Cache。
  • 已有查询、删除和回收继续运行。
  • 不影响目标进程。
  • 日志限频。
  • 提供 rejected、reclaimed、retired、oldest wait 等运行指标。

普通 hash 查询热路径没有增加容量统计、容量锁或日志判断。


八、修复 ring 失败后的所有权泄漏

Proc Cache 已经从 hash 删除,但 proc_event_ring 分配或入队失败时,以前对象会变成无人负责:

hash 中没有 p
ring 中没有 p
retired list 中没有 p

现在 ring 失败后由当前线程处理:

free_symbolizer_cache_kvp(&kv);

根据引用情况:

use == 0:立即释放
use > 0:进入 retired list

避免对象、symbol cache 和容量名额永久泄漏。


九、增加远程诊断入口

deepflow-core 独立提交:

c2408af feat: expose proc cache diagnostics via remote exec

debug.rs 注册远程命令:

proc-cache-reclaim

支持:

  • 可选 older-than
  • 默认 60 秒。
  • 参数校验。
  • 通过 RPC 调用 deepflow-ebpfctl

最终效果

经过这一系列修改,现在形成了三层保护:

第一层:Java refresh 超时和 task 生命周期修复
        防止引用本身长期不归还

第二层:reader 生命周期保护 + retired 延迟回收
        即使引用未归还,也不发生 UAF、不让 proc-events 忙等

第三层:Proc Cache 总量限制
        即使引用永久泄漏,也不允许内存无限增长

最终保证:

  • proc-events 不再因为等待 p->use == 0 耗尽一个 CPU。
  • hash_search → use++ 不再存在释放竞态。
  • Java symbol task 不再无限等待或访问已释放 task。
  • 长期引用对象安全进入 retired list。
  • 能看到等待时间、占用内存和引用原因。
  • ring 失败不会丢失对象所有权。
  • Proc Cache 对象总量存在硬上限。
  • 正常 Proc Cache 查询热路径未增加全局容量锁。

当 Proc Cache 达到容量上限时,会立即打印一条 WARNING:
Proc cache limit reached: total=65536 limit=65536 active=54120 retired=11320 rejected=1823
此时不会继续创建新的 Proc Cache,避免内存无限增长。持续满载时,每两小时最多重复打印一次,防止进程风暴变成日志风暴。
容量恢复到上限以下时,会立即打印一条 INFO:
Proc cache capacity recovered: total=65480 limit=65536 rejected=1841

@yinjiping

Copy link
Copy Markdown
Contributor Author

必须修改的问题

1. 修复 worker 获取错误 thread_index 的并发问题

位置:

  • worker_thread()
  • thread_pool_add_task()

当前通过共享变量传递新 worker 的索引:

pool->thread_index = pool->thread_count;
pthread_create(&thread, NULL, &worker_thread, pool);

worker 启动后再读取:

int thread_idx = pool->thread_index;

如果短时间连续创建两个 worker,第一个 worker 可能在线程启动较晚时读到第二个 worker 的 index,随后因为线程 ID 不匹配而退出:

if (pool->threads[thread_idx].thread != thread)
    pthread_exit(NULL);

可能造成:

  • thread_count 仍包含已经退出的 worker;
  • Java symbol task 永久留在 pending queue;
  • collector 永远不启动;
  • task/socket/refcount 长期不释放。

必须修改的原因

这是实际的任务永久卡住风险,而且当前 MR 正在修改同一段线程池创建逻辑。若不处理,MR 虽然修复了一类永久等待,但仍可能通过另一条路径制造永久 pending。

建议修改

不要使用共享的 pool->thread_index 传递 index。可以:

  • 给每个 worker 传独立的 {pool, index} 启动参数;或
  • worker 获取 pool->lock 后,通过 pthread_equal() 查找自己的 slot。

2. 修正 pthread_cond_timedwait() 的超时判断和 deadline

位置:

update_java_symbol_file()

当前实现:

while (task->need_refresh) {
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    ts.tv_sec += JAVA_SYMS_REFRESH_TIMEOUT_SECS;

    refresh_rc =
        pthread_cond_timedwait(&task->cond, &task->mutex, &ts);

    if (refresh_rc == ETIMEDOUT) {
        task->need_refresh = false;
        task->update_status = -1;
        break;
    }
}

这里有两个相关问题,应当合并成一个 Review 意见。

2.1 timeout 后没有重新检查 predicate

刷新完成和 timeout 可能同时发生。collector 可能已经执行:

task->update_status = 0;
task->need_refresh = false;
pthread_cond_signal(&task->cond);

pthread_cond_timedwait() 仍可能在边界条件下返回 ETIMEDOUT。当前代码会直接把成功结果覆盖成:

task->update_status = -1;

应该先检查:

if (!task->need_refresh)
    break;

只有 predicate 仍然为 true,才认定刷新超时。

2.2 deadline 不应在循环内重新计算

当前每次 spurious wakeup 都会重新获得 10 秒等待时间,因此不能严格保证整个调用最多等待 10 秒。

deadline 应在进入循环前计算一次,后续重复使用。

建议结构

struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += JAVA_SYMS_REFRESH_TIMEOUT_SECS;

while (task->need_refresh && !task->stopped) {
    int rc = pthread_cond_timedwait(
        &task->cond, &task->mutex, &deadline);

    /* 先检查业务条件,处理 signal/timeout 边界竞争。 */
    if (!task->need_refresh || task->stopped)
        break;

    if (rc != 0) {
        task->need_refresh = false;
        task->update_status = -1;
        break;
    }
}

更理想的是使用 CLOCK_MONOTONIC,但如果本 MR 希望控制范围,至少必须做到:

  • deadline 只计算一次;
  • timedwait 返回后先检查 predicate;
  • 处理所有非零返回值。

必须修改的原因

“保证 Java symbol update 在有限时间内返回”是本 MR 的核心目标。如果 deadline 可以反复延长,或者成功结果会被 timeout 覆盖,说明核心等待逻辑还不完整。

合入前置条件

3. Rebase 最新 v6.6 并正确解决冲突

当前 PR 与 v6.6 存在冲突,相关 verify checks 也因此没有运行。

最新 v6.6 中已经存在大量 Java attach preflight 逻辑,而 MR 当前 head 中没有这些代码。解决冲突时必须保留:

  • JVM 类型和版本检查;
  • Java 8 安全版本限制;
  • DisableAttachMechanism 检查;
  • PID reuse 检查;
  • namespace/version helper;
  • preflight timeout 等逻辑。

这不需要写成一条具体的代码 Review comment,但它是合入的必要前提。冲突解决后需要重新看最终 diff。

不作为本 MR 阻塞项的问题

下面这些可以不要求当前 MR 修改:

proc-events 最多仍可能忙等 10 秒

while (AO_GET(&p->use) != 0)
    CLIB_PAUSE();

它确实意味着异常时仍可能短时间高 CPU,但本 MR 的核心价值是把“永久忙等”变成“有界等待”。如果团队接受这一行为,可以后续单独优化,不阻塞本 MR。

pthread_detach() 使用 errno 打印错误

这是错误日志准确性问题:

strerror(errno)

应该使用 pthread_detach() 的返回值,但不是核心稳定性问题,可以顺手改,也可以后续处理。

refcount assert、mutex/cond destroy

属于健壮性和代码规范优化,不是当前已确认的功能阻塞项。

正常刷新使用 broadcast

当前 Java symbol update 是单线程消费,通常只有一个 waiter,因此不要求本 MR 必须从 signal 改成 broadcast

18564ae fix: make Java symbol refresh timeout reliable
09291ca fix: bind Java symbol workers to correct pool slots
已修复

**Problem (high CPU)**

exec_proc_info_cache_update() processes process-exit events from
proc_event_ring and eventually calls free_symbolizer_cache_kvp() when a
process is evicted from the symbolizer proc-info cache. That function busy
waits for every in-flight reference to drain:

    AO_DEC(&p->use);
    /* Ensure that all tasks are completed before releasing. */
    while (AO_GET(&p->use) != 0)   /* never sleeps; burns one core */
        CLIB_PAUSE();
    free_proc_cache(p);

p->use fails to reach zero when a Java symbol-file refresh task keeps its
reference forever. The refresh path was update_java_symbol_file() waiting
unconditionally with pthread_cond_wait() for ipc_receiver_main (the Java
collector thread) to finish generating the symbol file. If that collector
thread exited abnormally or hung without signalling, the waiter blocked
indefinitely, java_syms_update_main() never reached its AO_DEC(&p->use),
and the next eviction of that process (exec_proc_info_cache_update ->
free_symbolizer_cache_kvp) spun forever in the while loop - sustained high
CPU on the proc-events thread.

While investigating we also found that the task could be freed while the
waiter still used it (no reference counting), and that several task/thread
pool failure paths left the task queued after create_symbol_collect_task()
had freed it, or double-closed the map/log sockets. Those were fixed as part
of making the refresh path reliably terminate so the busy wait can make
progress.

**Changes made**

All changes are in agent/src/ebpf/user/profile/java/jvm_symbol_collect.c
and its header.

1) Bounded, wakeable symbol-file refresh wait (fixes the stuck reference)
   - update_java_symbol_file() now takes a task reference first and, instead
     of pthread_cond_wait() forever, waits in a
     while (need_refresh) + pthread_cond_timedwait() loop bounded by
     JAVA_SYMS_REFRESH_TIMEOUT_SECS (10s). On timeout it clears need_refresh,
     sets update_status = -1 and returns, so java_syms_update_main() can
     finish and drop its p->use reference; the caller never holds a
     symbolizer_proc_info (p->use) forever and proc-events never spins.
   - The collector marks the task stopped (stopped = true, update_status =
     -1, need_refresh = false) under task->mutex and broadcasts task->cond
     when it exits, so refresh waiters wake up immediately instead of waiting
     out the timeout on a collector that is gone.

2) Task lifetime reference counting (fixes use-after-free of the task)
   - Added volatile int ref_count and volatile bool stopped to struct task.
   - Split destroy_task() into put_task_ref(): decrement ref_count under the
     pool lock and call destroy_task() only when it reaches zero. The worker
     owns the base reference from enqueue until the task is removed from its
     slot; update_java_symbol_file() takes an extra reference through
     get_ref_task_by_pid() while holding the pool lock, so the task is never
     freed while the refresh waiter is still touching it.
   - Worker teardown clears the pool slot and calls put_task_ref() instead of
     destroying the task directly; update_java_symbol_file() calls
     put_task_ref() on every return path (pid gone / stale task / stopped /
     refresh done / timeout).

3) Thread-pool slot growth and enqueue-failure rollback
   - Grow the slot array with realloc() and reserve the new slot before
     pthread_create(); publish thread_count only after the thread has been
     created and detached. This removes the heap out-of-bounds read in loops
     iterating pool->threads[0..thread_count) when realloc() failed after
     ++pool->thread_count had already run.
   - On failure of realloc()/pthread_create()/pthread_detach(), unlink the
     already enqueued task (list_head_del + task_count--/pending_tasks--) so
     "failed" means "never enqueued"; create_symbol_collect_task() can then
     free the task without leaving a dangling queue entry that a worker would
     later dequeue after it was freed (use-after-free / corrupted queue).
   - On pthread_detach() failure the worker already exists and keeps running,
     so its slot and thread_count stay published (the pool has no stop/join
     teardown, so no zombie accumulates at runtime and the kernel reclaims
     threads at process exit); only the task is rolled back.

4) Double-close guard on the enqueue-failure path
   - In create_symbol_collect_task(), on add_task failure call put_task_ref()
     (which frees the task and the sockets moved into its args when the count
     reaches zero) and set the local map_socket/log_socket to -1 so the
     cleanup label does not close the same fds a second time (double close
     could silently close an unrelated fd once the number was reused).

**Impact scope**

- Affects only the eBPF Java symbol collector
  (agent/src/ebpf/user/profile/java/).
- The Java symbol-file refresh wait is now bounded (10s) and wakeable, so a
  hung/exited collector thread no longer leaves p->use elevated forever and
  no longer causes free_symbolizer_cache_kvp() to spin (high CPU) when the
  process is evicted from the proc-info cache.
- Reference-counted task lifetime prevents the refresh waiter from touching a
  task that the worker has already released.
- Failure paths of thread-pool enqueue no longer leave dangling queued tasks
  or double-close the map/log sockets.
- The new fields change the layout of struct task at compile time only; there
  is no external ABI dependency.

**Verification plan**

- Authoritative host (10.50.5.27, v6.6 branch): gcc -std=gnu99 -fsyntax-only
  -Wall (with the full include set / macros / Java agent definitions) passes,
  SYNTAX_OK, no new warnings.
- High-CPU scenario: make the Java collector thread exit/hang during a symbol
  refresh and evict the process through exec_proc_info_cache_update();
  confirm free_symbolizer_cache_kvp() no longer spins and update_java_symbol_file()
  returns within ~10s.
- Failure injection: realloc/thread-create failure returns -1/-2 with the
  queue counts rolled back and no double close in create_symbol_collect_task().
- Java agent end-to-end regression: multi-PID collection, process exit/recreate
  and concurrent symbol-file refresh.

**Branches involved**

* v6.6 (fix branch on the authoritative host); merge into later releases as
  planned.

**Checklist**

- [ ] Dependency update needed (no)
- [x] Common problem (bounded wait / reference counting reusable for other
      tasks in the same pool)
- [x] Compiles (gcc -fsyntax-only SYNTAX_OK)
- [ ] Unit tests pass (to run on the authoritative host per the verification
      plan)
A bihash lookup returned a proc-info pointer before p->use was incremented. A concurrent process-exit path could remove the entry, transfer its base reference to proc-events, and free it while the reader was descheduled in that window.

Use cache-line-separated reader slots selected by TLS thread_index around hash_search -> AO_INC. Serialize writer search-and-delete and full-table walks, then wait for all acquisition slots after removal before transferring or releasing the hash-owned reference.

Failure sequence:

reader                 sk-reader              proc-events
  hash_search -> p
  descheduled
                       delete p
                       enqueue p
                                              use 1 -> 0
                                              free p
  AO_INC p->use  [UAF]

Fixed sequence:

reader slot            sk-reader              proc-events
  slot active++
  hash_search -> p
  descheduled
                       lock writer
                       delete p
                       unlock writer
                       wait reader slots
  AO_INC p->use
  slot active--
                       enqueue p
                                              drop base ref
                                              wait p->use == 0
                                              free p
free_symbolizer_cache_kvp() dropped the cache ownership reference and then busy-waited for every reader to release its reference. A long-lived or leaked reference could therefore make the proc-events thread spin indefinitely, consume CPU, and stop processing subsequent exec and exit events.

Drop the ownership reference without waiting. Reclaim immediately when it was the last reference; otherwise link the object into a proc-events-owned retired list and retry reclamation during later cache-update iterations. The intrusive list requires no allocation, and objects are never freed until their reference count reaches zero.
A retired proc cache object may remain referenced after its ownership reference is dropped, while the use count alone does not identify which long-lived acquisition path last retained it.

Add an atomic use_reason marker and helpers for recording and reading it. Mark hash-query and Java symbol-task acquisitions, keep temporary local references unchanged, and preserve the marker after reference release for later diagnostics.
Deferred process caches can remain referenced after hash removal, but there was no way to inspect how many were waiting, how long they had waited, or which long-lived reference last retained them.

Record a monotonic timestamp on first retirement and protect the retired list with a dedicated mutex. Build diagnostic snapshots while the list is stable, then keep expensive cache destruction outside the lock.

Add 'deepflow-ebpfctl proc-cache-reclaim show --older-than' to report active and retired counts, accounted memory, process start time, wait duration, symbol-cache state, and the last reference-increment reason. The total count excludes objects in proc_event_ring; the hash has an arena-size limit rather than a fixed entry-count limit.
Deferred proc caches can retain references after leaving the hash, while new processes continue allocating replacements. The bihash arena does not cover proc-info objects or symbol caches, so a leaked reference or process storm could grow memory without an object-count bound.

Add a configurable total limit covering reserved, active, proc-event ring, and retired objects. Reserve slots with CAS before both allocation paths, return them on every initialization failure, and release them only after final cache destruction. Preserve ownership and reclaim locally when proc-event ring allocation or enqueue fails.

Expose capacity, rejection, reclamation, and oldest-wait metrics; extend proc-cache-reclaim diagnostics with true total usage and bounded detail output; rate-limit capacity logs; and support validated startup and hot-update configuration without adding work to proc-cache lookup paths.
Workers read a shared pool thread index before acquiring the pool lock. A delayed worker could observe the index assigned to a later worker, fail the thread identity check, and exit while thread_count still included it. Long-lived Java symbol tasks could then remain pending without an available worker.

Remove the shared startup index. Each worker now acquires the pool lock and locates its fully published slot with pthread_equal(). The creator holds the same lock through slot publication, so every worker binds to its own stable slot regardless of scheduling order.
The refresh waiter recalculated a CLOCK_REALTIME deadline after every wakeup, allowing spurious wakeups or wall-clock changes to extend or shorten the wait. A timeout racing with completion could also overwrite a successful refresh, while other condition-wait errors were ignored.

Initialize the task condition variable with CLOCK_MONOTONIC, compute one deadline per refresh, recheck the predicate before handling the wait result, and fail all nonzero wait errors. Clean up the synchronization objects with the task and report pthread_detach failures using its returned error code instead of errno.
@kylewanginchina

kylewanginchina commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

下面这个问题还需要再看一下:
image

rvql
rvql previously approved these changes Sep 10, 2026
symbols_cache_update could free and replace a resolver while symcache_resolve was still using it because the update and resolve paths used different locks. This allowed resolver fields to be accessed after bcc_free_symcache.

Protect resolver replacement with the per-process mutex and validate the resolver while holding that mutex. Release the resolve mutex before taking the process state lock, then use a short p->lock-to-mutex recheck so stale misses cannot mark a replacement resolver without introducing a reverse lock order.
Retired proc-info objects could retain large BCC symbol caches indefinitely while outstanding references delayed final object reclamation.

Detach resolvers under the per-process mutex, free them outside the retired-list lock in bounded batches, and keep the proc-info object until its reference count reaches zero. Prevent updates from rebuilding caches for exited objects and use atomic access for lifecycle fields shared across locks.
The proc-events creator continued dereferencing a proc-info object after publishing it in the hash. A concurrent exec or exit could remove the entry and free it when event-ring enqueue failed, causing a use-after-free. The post-publication mount-cache registration also duplicated the registration performed during initialization and over-counted proc_count.

Register mount information exactly once before publication, make the add helper return no proc-info pointer, and stop accessing the object after a successful hash insertion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants