跳过正文

多线程下RDMA的崩溃问题

作者
杨全烨
系统软件:操作系统、网络与分布式系统。
目录

本文记录了作者尝试解决这个[CRASH] Does Valkey over RDMA not support I/O threads? #3112 的问题.

https://github.com/valkey-io/valkey/issues/3112

总结一次实验的workflow,这是拉的战线最长的一次,前前后后整了一个多月还没merge进去.

server端

# 解开当前进程的内存锁
sudo prlimit --memlock=unlimited --pid $$
# 创建在内存中工作的虚拟网卡
sudo modprobe dummy
sudo ip link add dummy0 type dummy
sudo ip link set dummy0 up
# 配置本地的IP
sudo ip addr add 10.0.0.1/24 dev dummy0
# 做RXE的绑定
sudo rdma link add rxe_dummy type rxe netdev dummy0
# 停止本来运行的 valkey
sudo systemctl stop valkey
# 编译一遍
make BUILD_RDMA=yes USE_FAST_FLOAT=yes
# 跑server
sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379

client端

# 解开当前进程的内存锁
sudo prlimit --memlock=unlimited --pid $$
# 直接做暴力压测,还可以尝试比这个参数更高的
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma

因为这是多线程,注意此时valkey的配置问题:

 io-threads 4
 save ""
 protected-mode no
 bind 0.0.0.0

抓取日志分析的方法

 # 拿一下server当前的PID:
 ./src/valkey-cli -h 10.0.0.1 -p 6379 INFO server | grep process_id
 # 拿到这个PID之后 抓线程栈
 sudo gdb -p $(PID) -ex 'thread apply all bt' -batch > /tmp/valkey_bt.txt
 # 看一下文件的前多少行
 head -n 60 /tmp/valkey_bt.txt
 
 
 # 抓取当前I/O队列的状态
 ./src/valkey-cli -h 10.0.0.1 -p 6379 INFO stats | grep -E 'clients_pending_io_read|clients_pending_io_write|io_threaded_reads_processed|io_threaded_writes_processed|instantaneous_ops_per_sec'
 # 看一下客户端的情况
 ./src/valkey-cli -h 10.0.0.1 -p 6379 CLIENT LIST | head -n 10
 # 查看系统积压的延迟
 ./src/valkey-cli -h 10.0.0.1 -p 6379 LATENCY LATEST
 
 # 看队列长度和事件计数,就是查事件的状态机
  ./src/valkey-cli -h 10.0.0.1 -p 6379 INFO stats | egrep 'clients_pending_io_read|clients_pending_io_write|io_threaded_reads_processed|io_threaded_writes_processed|instantaneous_ops_per_sec'

同时还要注意格式的整理

 clang-format -style=file -i src/networking.c

尝试复现多线程RDMA崩溃问题
#

首先做带有RDMA的编译:

  make BUILD_RDMA=yes USE_FAST_FLOAT=yes

1.创建一个dummy虚拟网卡(纯粹在内存中模拟,wlan0环境可能不稳定?)

 sudo modprobe dummy
 sudo ip link add dummy0 type dummy
 sudo ip link set dummy0 up

2.给虚拟网卡配置一个本地的IP

 sudo ip addr add 10.0.0.1/24 dev dummy0 # 这个地址是否是可行的?

3.SoftRoCE绑定到这个dummy0上面去

# 如果之前的 rxe0 还在,可以先删掉: sudo rdma link del rxe0
sudo rdma link add rxe_dummy type rxe netdev dummy0

4.运行server端:

./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379

如果存在端口的冲突,那么:

sudo systemctl stop valkey

5.然后我们的客户端针对虚拟IP进行压测:(这个压测的量是比较大的,注意内存锁的问题)

./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 5 -r 100000 -n 12000000 -c 30 -t get --rdma

瞬间报错,证明最新的主线分支尚存在此问题.

但是客户端报错是:

~/Project/valkey unstable ⇡ ❯ ./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 5 -r 100000 -n 12000000 -c 30 -t get --rdma
Could not connect to server at 10.0.0.1:6379: RDMA: reg send mr failed

这是很激进的压测,那么RDMA在访问内存的原理和DMA直接访问用户态内存的原理是类似的,我们都需要锁定一部分内存,防止Linux给我们交换到了swap分区.

在 Arch Linux 下,普通用户的“最大锁定内存限制(memlock)”通常是有限的。当内存锁定达到上限时,rdma_reg_mr() 这个底层 API 就会失败,导致客户端发送出不完整的报文或直接断开。客户端的异常断开或畸形报文,瞬间触发了 Server 端的那个清理内存的 Bug -> 就是这也有可能是原因之一?

看一下是否开了内存锁:

# 在linux内部,内存是流动的,就是会和我们的swap分区进行不断的交换
# 那么这意味着一个进程最多锁定 8MB的内存来使用,这样对DMA肯定不友好,因为DMA要锁定一部分内存专门用来做交换
~ ❯ ulimit -l
 8192
# 所以我们尝试修改内存限制, 注意,这个也只是针对当前的进程的
~ ❯ sudo prlimit --memlock=unlimited --pid $$
# 我们需要验证当前进程确实没有了限制,那么这样确实就没有了限制,都是unlimited
~ ❯ cat /proc/self/limits | grep "Max locked memory"
Max locked memory         unlimited            unlimited            bytes
# 那么这样的应用应该放在client和server上面去

客户端(发送方):需要分配一块内存存放要发送的 GET/SET 命令,并把它锁定。网卡会直接从这块内存把数据搬走发到网络上。

服务端(接收方):也必须提前分配好一块内存,并把它锁定。网卡收到数据后,会直接越过 CPU,把数据写进这块锁定的内存里。

到底是客户端发送了错误的报文还是多线程导致的?
#

先跑一个单线程的测试:

~/Project/valkey unstable ⇡ ❯ ./src/valkey-benchmark -h 10.0.0.1 -p 6379 -c 1 -n 1000 -t get --rdma
====== GET ======
  1000 requests completed in 0.07 seconds
  1 parallel clients
  3 bytes payload
  keep alive: 1
  host configuration "save": 3600 1 300 100 60 10000
  host configuration "appendonly": no
  multi-thread: no

Latency by percentile distribution:
0.000% <= 0.039 milliseconds (cumulative count 113)
50.000% <= 0.055 milliseconds (cumulative count 726)
75.000% <= 0.063 milliseconds (cumulative count 931)
93.750% <= 0.071 milliseconds (cumulative count 949)
96.875% <= 0.119 milliseconds (cumulative count 971)
98.438% <= 0.151 milliseconds (cumulative count 988)
99.219% <= 0.175 milliseconds (cumulative count 994)
99.609% <= 0.215 milliseconds (cumulative count 997)
99.805% <= 0.247 milliseconds (cumulative count 999)
99.902% <= 1.559 milliseconds (cumulative count 1000)
100.000% <= 1.559 milliseconds (cumulative count 1000)

Cumulative distribution of latencies:
96.500% <= 0.103 milliseconds (cumulative count 965)
99.600% <= 0.207 milliseconds (cumulative count 996)
99.900% <= 0.303 milliseconds (cumulative count 999)
100.000% <= 1.607 milliseconds (cumulative count 1000)

Summary:
  throughput summary: 14084.51 requests per second
  latency summary (msec):
          avg       min       p50       p95       p99       max
        0.056     0.032     0.055     0.079     0.159     1.559

单线程下肯定是没什么问题,实际上在有内存锁的情况下,这个最多只能放到3,之后就会报错,这虽然不是重点,但是Client就算内存不够,通信出现问题,server也不应该这样退出,这是有问题的!

放开内存锁之后,发现在最新的版本上好像确实没有这个问题了.

但是实际上,只是这个错误不能 100%复现,只要使用比如这个配置文件:

io-threads 8 # 使用8个线程
save ""
protected-mode no
bind 0.0.0.0

我们使用这样的模式来进行压力测试,我们开启 -P 参数,原来的benchmark采取的是ping-pong的模式,也就是每当一个命令执行完,client收到结果之后,才会去发送下一个.

但是 -P 流水线的方式,把32个GET请求放在一起给server,server进行解析,解析完成之后,才会返回.

./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma

1.实际上,生产环境都会强制使用pipelining来提高生产力,所以这样的测试是有价值的!

在高性能网络编程中,最大的性能杀手是 RTT(Round Trip Time,网络往返延迟)。 如果采用你所说的“发一个命令,等一个结果”的 Ping-Pong 模式,即使服务器处理命令只需要 1 微秒,但网络传输一来一回可能需要 100 微秒。大部分时间都在空等。 为了压榨出极限的吞吐量(比如达到百万级 QPS),真实的业务系统(如大规模缓存预热、批量写入)都会强制开启 Pipelining,一次性把几十上百个命令塞进一个 Socket 发过去。

因此,Valkey 必须能够完美、稳定地处理 Pipelining 请求,这是它的核心能力基石。 如果一个服务端程序一遇到 Pipelining 就崩溃,那它绝对是一个高危的 P0 级 Bug。

2.**cmd_queue** 到底是怎么工作的?

你可能会问,既然 Pipelining 会让多个命令同时到达,那 Server 是怎么处理的?这就引出了引发崩溃的那个主角:cmd_queue

在 Valkey 9.0 引入新的 I/O 多线程模型后,架构是这样的:

  1. 网卡收包:RDMA 网卡把包含 32 个 GET 命令的巨大数据块直接 DMA 到内存中。

  2. I/O 线程解析:后台的 I/O 线程被唤醒,开始读取这块内存。它通过词法分析,把这个字节流切分成了 32 个独立的命令对象,然后依次 push 到这个客户端的 c->cmd_queue 中。此时,**c->cmd_queue.len** 就是 32。

  3. 主线程执行:主线程接管,发现队列里有 32 个命令,于是依次执行它们,并将结果写回输出缓冲区。执行完毕后,清空队列(len 重新归 0)。

**也就是说在这里,I/O线程和主线程是交替工作的?**这是轮转决定的?

现在,这个错误就是100%可以复现的了,我们可以着手进行解决.

git使用
#

比如有这样一种情景:

我们不小心

git add .
git commit -m "......"

但是突然发现,有一个文件是我们实验是才会使用的,比如valkey.conf,那么你就可以

git checkout HEAD^ -- valkey.conf

来恢复这个文件,然后我们继续修正上一次的commit

git commit --amend --no-edit

这样就OK了.

踩坑 RDB文件
#

valkey.conf中的配置

save "" # 那么此时就是纯缓存系统,我们不会保存RDB文件.

假如是:

save 3600 1 # 假如3600s之内,有一个key发生了变化,我们就fork来触发一个快照的任务.

但是在做RDMA性能测试的时候就会踩坑,如果你在一个分支中进行了测试,但是保存了RDB文件,切分支的时候,因为dump.rdb在.gitignore中,那么这个文件也会带过来,然后你再次使用benchmark做测试的时候,server启动的时候会把这个rdb文件加载进来,那就会造成段错误(我的疑惑是正常的情况下是否会造成影响,就是即使加载随便一个rdb文件进来,我在做benchmark测试的时候比如./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma 是不是也应该正常进行?).

这也是我发现的一个问题,之后再进行处理,可以提一个issue来做.

解决CI中的两个crash的问题
#

现在我们已经解决了多线程崩溃的问题,但是不知道CI中还是会出现两个crash,我还是怀疑pizhenwei给出的方案是存在问题的,确实是存在问题的,就是状态机还是存在漏洞.

我们valkey的测试,使用TCL(tool command language 很多E2E的测试都是这样)

test-ubuntu-io-threads
#

报错,日志里最后正好是 Starting test WAITAOF when replica switches between masters, fsync: no,然后测试客户端读回复报 I/O error,说明是“等待回复阶段连接被断了/状态错乱了”。

多跑几遍来进行复现:

for i in {1..100}; do
  echo "run $i"
  ./runtest --io-threads --accurate --verbose --dump-logs --single unit/wait || break
done
./runtest \
  --io-threads \
  --accurate \
  --verbose --verbose \
  --dump-logs \
  --dont-clean \
  --stop \
  --tags network \
  --single unit/wait \
  --only "WAITAOF when replica switches between masters, fsync: no" \
  --loops 500

看起来是主从复制的时候出了问题?

本地完全复现不出来这个错误我靠,难道和linux版本还有关系?

首先你要理解CI在做什么,首先会merge,就是把你的修改merge到当前的最新分支编译,然后进行测试,就是去看你的代码合并进来之后会不会产生问题.

这里的问题就是本地应该怎么去做CI的复现对吧,其实可以看到https://github.com/valkey-io/valkey/blob/unstable/.github/workflows/daily.yml 去观察一次workflow的情况究竟是怎么样的.

比如这个:

 test-ubuntu-io-threads:
    runs-on: ubuntu-latest
    if: |
      (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' ||
        (github.event_name == 'schedule' && github.repository == 'valkey-io/valkey') ||
        (github.event_name == 'pull_request' &&
          (github.event.pull_request.base.ref != 'unstable' ||
            contains(github.event.pull_request.labels.*.name, 'run-extra-tests')) &&
          (github.event.action != 'labeled' ||
            (github.event.pull_request.base.ref == 'unstable' && github.event.label.name == 'run-extra-tests'))
        )) &&
      !contains(github.event.inputs.skipjobs, 'iothreads')
    timeout-minutes: 1440
    steps:
      - name: prep
        if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
        run: |
          echo "GITHUB_REPOSITORY=${{inputs.use_repo || github.event.inputs.use_repo}}" >> $GITHUB_ENV
          echo "GITHUB_HEAD_REF=${{inputs.use_git_ref || github.event.inputs.use_git_ref}}" >> $GITHUB_ENV
          echo "skipjobs: ${{github.event.inputs.skipjobs}}"
          echo "skiptests: ${{github.event.inputs.skiptests}}"
          echo "test_args: ${{github.event.inputs.test_args}}"
          echo "cluster_test_args: ${{github.event.inputs.cluster_test_args}}"
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          repository: ${{ inputs.use_repo || github.event.inputs.use_repo || github.repository }}
          ref: ${{ inputs.use_git_ref || github.event.inputs.use_git_ref || github.ref }}
      - name: Install libbacktrace
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          repository: ianlancetaylor/libbacktrace
          ref: b9e40069c0b47a722286b94eb5231f7f05c08713
          path: libbacktrace
      - run: cd libbacktrace && ./configure && make && sudo make install
      - name: make
        run: make SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes
      - name: testprep
        run: sudo apt-get install tcl8.6 tclx
      - name: test
        if: true && !contains(github.event.inputs.skiptests, 'valkey')
        run: ./runtest --io-threads --accurate --verbose --tags network --dump-logs ${{github.event.inputs.test_args}}
      - name: cluster tests
        if: true && !contains(github.event.inputs.skiptests, 'cluster')
        run: ./runtest-cluster --io-threads ${{github.event.inputs.cluster_test_args}}

这是ubuntu版本中运行的对吧,那么最好的方法就是起一个ubuntu的docker,配置对应的环境从而进行编译和测试.

用 Ubuntu Docker 容器 + 一份临时 worktree + 按 daily.yml 的方式编译和跑测试。

开一个ubuntu的容器来做测试
#

# 在宿主机上准备worktree,防止产生影响
cd /home/ada/Project/valkey
git fetch upstream
git worktree add ../valkey-ci-repro fix-rdma-io-threads
cd ../valkey-ci-repro
git merge upstream/unstable

然后我们起一个ubuntu的容器:

# 容器内部操作
docker run --rm -it \
  --name valkey-ci-repro \
  -v "$PWD":/work \
  -w /work \
  ubuntu:24.04 \
  bash
# 安装基础环境
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
  build-essential \
  pkg-config \
  git \
  wget \
  ca-certificates \
  tcl8.6 \
  tclx \
  libssl-dev
# 之后模拟workflow的操作
cd /tmp
git clone https://github.com/ianlancetaylor/libbacktrace.git
cd libbacktrace
git checkout b9e40069c0b47a722286b94eb5231f7f05c08713
./configure
make -j"$(nproc)"
make install
ldconfig
# 然后回仓库,按照CI的参数重新编译
cd /work
make distclean
make SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes -j"$(nproc)"

# 之后再使用容器的话可以这样
docker start -ai valkey-ci-repro

# 如果已经在后台运行的话
docker exec -it valkey-ci-repro bash

本地根本跑不出来,都模拟成这样了?

后面可以建立一个DockerFile:

FROM ubuntu:24.04

RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
    build-essential \
    pkg-config \
    git \
    wget \
    ca-certificates \
    tcl8.6 \
    tclx \
    libssl-dev \
 && rm -rf /var/lib/apt/lists/*

RUN cd /tmp \
 && git clone https://github.com/ianlancetaylor/libbacktrace.git \
 && cd libbacktrace \
 && git checkout b9e40069c0b47a722286b94eb5231f7f05c08713 \
 && ./configure \
 && make -j"$(nproc)" \
 && make install \
 && ldconfig

WORKDIR /work

这里查不出来,先放着.

其实还是在找状态机的时序漏洞.

crash2 TLS卡死
#

~/Project/valkey fix-rdma-io-threads* 2m 17s ❯ ./runtest --io-threads --tls --accurate --verbose --dump-logs --single unit/wait

发现这个测试是存在问题的.

卡住了,你怎么进行排错:

ps aux | grep valkey
# aux: a 表示显示所有用户的进程,u 表示以面向用户的详细格式输出(包含 CPU、内存占用等),x 表示显示没有控制终端的后台进程。

这里要讲一下TLS的编译了:

# 编译条件,注意写一些CI
make BUILD_TLS=yes SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes

# 生成测试证书
./utils/gen-test-certs.sh

# 检查TLS
tclsh <<< 'package require tls; puts OK'

这里比较有意思,我们来做了一套对比的实验:

1.基线:

./runtest --accurate --verbose --verbose --dump-logs --single unit/protocol

2.只有 TLS:

./runtest --tls --accurate --verbose --verbose --dump-logs --single unit/protocol

3.只有 io-threads:

./runtest --io-threads --accurate --verbose --verbose --dump-logs --single unit/protocol

4.TLS + io-threads:

./runtest --io-threads --tls --accurate --verbose --verbose --dump-logs --single unit/protocol

只有第四组会挂掉,就是这里的状态机接力的时候出现了问题.

这里的手法就是首先理解TLS状态机和普通TCP状态机时序上的差异.

CI报了24个错误?
#

随便挑一个CI看一下https://github.com/valkey-io/valkey/actions/runs/23701289010/job/69045307291?pr=3335

分析一下,报的都是一类错误,分析一下测试为什么会挂住,这是TCL测试:

test_slave_buffers {slave buffer are counted correctly} 1000000 10 0 1

参数含义:cmd_count=1000000, payload_len=10, limit_memory=0, pipeline=1

完整流程:

  1. 启动 master + replica 两个实例

  2. 创建 100 个 key,每个 100KB (共约 10MB 数据)

  3. replica 连接 master 并完成全量同步

  4. 用 SIGSTOP 暂停 replica 进程 (pause_process $slave_pid) —— replica 完全"冻结",不读不写

  5. 创建一个 valkey_deferring_client 连接到 master

  6. 在紧密循环中发 1,000,000 条 SETRANGE key:0 0 AAAAAAAAAA 命令(pipeline 模式,只发不收)

  7. 发完后再循环读 1,000,000 条回复

  8. 检查 master 的 mem_clients_slaves 是否正确反映了积压的 replica 输出缓冲区

TCP流控dead lock
#

这是两个独立的 TCP 数据流:

数据流 A:Client → Server(命令)

Tcl 进程 → write() → Client 内核发送缓冲区 → [TCP] → Server 内核接收缓冲区 → read() → Valkey Server

数据流 B:Server → Client(回复)

Valkey Server → write() → Server 内核发送缓冲区 → [TCP] → Client 内核接收缓冲区 → [无人读取!]

关键机制:TCP 有接收窗口 (receive window)。当接收端的接收缓冲区满了,发送端就不能再发了。

死锁的形成过程
#

阶段 1:一切正常

  • Client 发命令,Server 读取并处理

  • Server 生成回复(每个 SETRANGE 回复约 5 字节 :10\r\n),写入内核发送缓冲区

  • TCP 将回复传输到 Client 的内核接收缓冲区

阶段 2:Client 接收缓冲区开始填满

  • Client 在紧密的 for 循环里,从不调用 read()

  • Client 的 TCP 接收缓冲区持续积累回复数据

  • Linux 的 TCP 接收缓冲区初始大小约 **128KB**(由 **net.ipv4.tcp_rmem** 的 default 值决定)

  • TCP 自动调优 (auto-tuning) 可以扩大到 ~6MB,但前提是应用层在活跃地读取

  • 因为 Client 不读,TCP 自动调优不会显著扩大接收缓冲区

阶段 3:死锁形成

当 Client 的接收缓冲区满了 (~128KB, 约 26,000 条回复):

Server → Client 方向:

Client 内核接收缓冲区满 → TCP 接收窗口变为 0 → Server write() 返回 EAGAIN

Server 的回复积压在用户态的 client->reply 链表中

Client → Server 方向:

理论上这个方向是独立的,但问题出在内核层面…

这里有一个关键的 TCP 内核行为:虽然 TCP 是全双工的,但在 Linux 内核的实现中,TCP 内存管理是共享的。当一个连接在一个方向上积压了大量未消费的数据,内核的 TCP 内存压力检测 (**tcp_mem**, **tcp_moderate_rcvbuf**) 可能会限制这个连接在另一个方向上的缓冲区大小。

这里要注意的一点就是linux的接收和发送缓冲区是共享的,所以会有压力检测(虽然是全双工的).

直接的问题是:当 Server 端积压了大量发不出去的回复数据,Server 的 TCP 发送缓冲区满了。这时 TCP 的拥塞控制和内存管理可能会间接影响同一连接上的接收窗口通告。

最终效果:

  1. Client 的 flush $fd(阻塞 write)被挂起 —— Client 内核的发送缓冲区满了

  2. Server 也写不出回复 —— Client 内核的接收缓冲区满了

  3. 双方都在等对方先让步 → 死锁


为什么是"有时挂、有时不挂"(flaky:古怪的)
#

死锁是否发生取决于精确的时序:

  • Client 发送速度:每条命令约 54 字节,flush 是阻塞 write() 系统调用。在快的 CI runner 上,1M 条命令可能在 10-20 秒内全部发完。

  • Server 处理速度:Server 在事件循环中读取 → 处理命令 → 生成回复。如果 Server 能在 Client 发完所有命令之前保持足够的接收缓冲区空间,就不会死锁。

  • TCP 缓冲区大小:受系统参数 tcp_rmem, tcp_wmem, tcp_mem 影响,不同 CI runner 可能不同。

  • 32-bit 的额外问题:32-bit 模式下内存分配开销更大,Tcl 解释器本身也更慢,更容易触发死锁窗口。

这解释了:

  • upstream #8453test-ubuntu-32bit 通过了(47分44秒)—— 那次 CI runner 可能更快,Client 在死锁窗口关闭前完成了所有发送

  • 你的 PR 的 test-ubuntu-32bit 失败了(22分后超时)—— CI runner 更慢,命中了死锁

说白了就是如果处理得太慢,就有可能会发生这种死锁.

还是有CI的错误需要处理
#

但是我不知道是偶现的还是经常性的.

docker run --rm -it \
  -v /home/ada/Project/valkey:/valkey \
  -w /valkey \
  alpine:latest sh -euxc '
  apk add --no-cache build-base git tcl procps tclx
  git clone --depth 1 https://github.com/ianlancetaylor/libbacktrace.git /tmp/libbacktrace
  cd /tmp/libbacktrace && git fetch --depth 1 origin b9e40069c0b47a722286b94eb5231f7f05c08713 && git checkout b9e40069c0b47a722286b94eb5231f7f05c08713
  cd /tmp/libbacktrace && ./configure && make -j"$(nproc)" && make install
  apk add --no-cache openssl-dev pkgconf # 加上openssl,装上开发依赖
  cd /valkey && make SERVER_CFLAGS="-Werror" USE_JEMALLOC=no CFLAGS=-DUSE_MALLOC_USABLE_SIZE USE_LIBBACKTRACE=yes -j"$(nproc)"
  cd /valkey && ./runtest --verbose --dump-logs
  '

我们之后怎么在不同版本的linux上进行CI crash的复现总结
#

docker会自己拉没有的容器.

$ docker ps # 列出容器
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
$ docker ps -a # 列出所有容器,包括已经停止的
CONTAINER ID   IMAGE                  COMMAND       CREATED        STATUS                      PORTS     NAMES
c3146944eaa9   debian:bookworm-slim   "/bin/bash"   3 months ago   Exited (130) 3 months ago             modest_allen

如果你想关闭容器之后能够保留:

docker run -it --name valkey-alpine \
  -v /home/ada/Project/valkey:/valkey \
  -w /valkey \
  alpine:latest sh

以后(容器若在跑):

docker start -ai valkey-alpine

若容器是 Exited 状态:

docker start valkey-alpine
docker exec -it valkey-alpine sh

测试都过了,可能只是远端的问题没有解决.

注意还会出现挂载权限的问题
#

  1. 之前在 Docker(Alpine)里用 v /home/ada/Project/valkey:/valkey 编过,容器里一般是 root,会在挂载目录里生成 属主为 root 的文件,例如 deps/libvalkey/obj/*deps/libvalkey/lib/libvalkey.a 等。

  2. 你现在在 宿主机上用普通用户 跑 ./runtest --tls,会触发 make,里面会对 deps/libvalkeyclean。日志里已有:

    rm: cannot remove 'obj/sockcompat.d': Permission denied
    
    ...
    
    make[3]: *** [Makefile:312: clean] Error 1
    

    说明 删不掉 root 拥有的产物,清理不完整。

  3. 接着编 TLS 版 libvalkey 时出现:

    fatal error: opening dependency file obj/tls.d: Permission denied
    

    典型就是 deps/libvalkey/obj 目录或里面文件仍是 root 只写,当前用户不能在里面写 .d 文件。

  4. 后面的 je_mallctl / je_malloc_usable_size 隐式声明,多半是在 依赖树没干净重编、.make-settings 和实际已编译的 jemalloc 状态不一致时的 连带现象;先把权限和 deps 清理修好,再全量 make,这类错误通常会消失。

怎么处理(在宿主机上)
任选一种可靠做法:

做法 A:只修属主(推荐,不动 git 跟踪文件)

sudo chown -R "$(id -un):$(id -gn)" /home/ada/Project/valkey/deps/libvalkey/obj \
  /home/ada/Project/valkey/deps/libvalkey/lib

若还有别的 Permission denied,可对整棵仓库做一次(注意会改所有构建产物的属主):

sudo chown -R "$(id -un):$(id -gn)" /home/ada/Project/valkey

然后:

cd /home/ada/Project/valkey && make distclean && make BUILD_TLS=yes -j"$(nproc)"

做法 B:以后在 Docker 里用当前用户映射(避免再出现)

docker run 时加 --user "$(id -u):$(id -g)"

(有时还需处理 $HOME),这样挂载目录里新文件属主是你的 UID,宿主机编译不会踩权限。

还是存在一个UAF问题?
#

I tried adding a test to test for use-after-free (took AI’s help) where some clients tried to send ping, and in the same batch there was a kill command issued.

Test: [a1b19c5](https://github.com/valkey-io/valkey/commit/a1b19c52a5946568343760f7df10d0179c36264d)

This test failed with ASAN - https://github.com/sarthakaggarwal97/valkey/actions/runs/24042292630/job/70116805349#step:7:530

Can we check if this is something we should investigate. The tests seems legitimate.

还是先尝试本地的复现
#

我们起了一个新的tcl测试,但是会发现出现了问题,先把这个测试加到我们的tcl中去.

proc stress_same_batch_client_kill_on_handled_clients {} {
    set server_pid [s process_id]
    set victim_count 16
    set iterations 100

    for {set iter 0} {$iter < $iterations} {incr iter} {
        for {set i 0} {$i < $victim_count} {incr i} {
            set victim($i) [valkey_deferring_client]
            $victim($i) client id
        }
        for {set i 0} {$i < $victim_count} {incr i} {
            set victim_id($i) [$victim($i) read]
        }
        set killer [valkey_deferring_client]

        # Build one late CLIENT KILL command that can synchronously free many
        # already-handled clients in the same batch. If the handled_clients
        # post-pass still dereferences those raw client pointers, ASAN should
        # catch it.
        set kill_args [list kill id]
        for {set i 0} {$i < $victim_count} {incr i} {
            lappend kill_args $victim_id($i)
        }

        # Queue all victim reads first, then queue the killer command while the
        # server is stopped so they are eligible for the same IO-thread batch.
        pause_process $server_pid
        for {set i 0} {$i < $victim_count} {incr i} {
            $victim($i) ping
            $victim($i) flush
        }
        $killer client {*}$kill_args
        $killer flush
        resume_process $server_pid

        assert_equal $victim_count [$killer read]

        for {set i 0} {$i < $victim_count} {incr i} {
            catch {$victim($i) read}
            catch {$victim($i) close}
            unset victim($i)
            unset victim_id($i)
        }
        $killer close

        # Keep the loop making forward progress so sanitizer failures point at
        # the batching window instead of a later idle teardown.
        assert_equal {PONG} [r ping]
    }
}

start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {enable-debug-command {yes} io-threads 5}} {
    # Skip if non io-threads mode - as it is relevant only for io-threads mode
    assert_equal {io-threads 5} [r config get io-threads]
@@ -102,3 +153,9 @@ start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overr
        }
    }
}

start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {io-threads 2 events-per-io-thread 0 use-exit-on-panic yes crash-memcheck-enabled no}} {
    test {ASAN canary for same-batch CLIENT KILL vs handled_clients post-pass} {
        stress_same_batch_client_kill_on_handled_clients
    }
}

Root Cause分析:

  1. CLIENT KILL 在批处理中执行: 当在同一个批次中,有些客户端发送 PING,同时有一个 CLIENT KILL 命令杀死这些客户端时
  2. 客户端被添加到 handled_clients: 在 processClientsCommandsBatch 中 (memory_prefetch.c:250-252):
  if (beforeNextClient(c) == C_OK) {
      listAddNodeTail(handled_clients, c);
  }
  3. 客户端在批处理中被 CLIENT KILL 释放: CLIENT KILL 命令会调用 freeClient(),直接释放那些 victim 客户端
  4. UAF 发生: 在 processIOThreadsReadDone 最后的循环中 (networking.c:6456-6460):
  while ((handled_ln = listNext(&handled_li))) {
      client *c = listNodeValue(handled_ln);  // c 已经被释放!
      if (!c->conn) continue;  // <-- ASAN 在这里触发 heap-use-after-free
      connUpdateState(c->conn);
  }

然后进行编译:

# 先清理之前的编译缓存,防止残留对象文件干扰
make distclean

# 使用 ASAN 选项编译,强制使用 libc 内存分配器
make SANITIZER=address -j$(nproc)

# 还要防止OOM杀死进程
sudo sysctl vm.overcommit_memory=1
  • MALLOC=libc:如果不加这个,Valkey 会链接 jemallocASAN拦截机制会失效或者直接导致编译/运行崩溃。

  • fno-omit-frame-pointer:告诉编译器不要优化掉栈帧指针(Frame Pointer)。这能让 ASAN 报错时,打印出非常清晰、深度的函数调用栈(Call Stack),就像你贴的日志里那样,方便你精准定位是哪一行触发的

首先dlopen我们要保证能找到对应的动态库,配置对应的路径.

export LD_LIBRARY_PATH=/home/ada/Project/valkey/src/modules/lua

然后运行单个的测试用例:

./runtest --single unit/io-threads
~/Project/valkey fix-rdma-io-threads* 4m 22s ❯ ./runtest --single unit/io-threads
Cleanup: may take some time... OK
Starting test server at port 21079
[ready]: 276318
Testing unit/io-threads
[ok]: Force the use of IO threads and assert active IO thread usage (1194 ms)
[err]: Sanitizer error: =================================================================
==276399==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ce588be6388 at pc 0x55bc4c205b5f bp 0x7ffcdada6970 sp 0x7ffcdada6960
READ of size 8 at 0x7ce588be6388 thread T0
    #0 0x55bc4c205b5e in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6458
    #1 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
    #2 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
    #3 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
    #4 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #5 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #6 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #7 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

0x7ce588be6388 is located 8 bytes inside of 576-byte region [0x7ce588be6380,0x7ce588be65c0)
freed by thread T0 here:
    #0 0x7f858a31f79d  (/usr/lib/libasan.so.8+0x11f79d) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
    #1 0x55bc4c207064 in freeClient /home/ada/Project/valkey/src/networking.c:2168
    #2 0x55bc4c210ea8 in freeClient /home/ada/Project/valkey/src/networking.c:2055
    #3 0x55bc4c210ea8 in clientKillCommand /home/ada/Project/valkey/src/networking.c:5262
    #4 0x55bc4c3335cf in call /home/ada/Project/valkey/src/server.c:3882
    #5 0x55bc4c33becb in processCommand /home/ada/Project/valkey/src/server.c:4568
    #6 0x55bc4c1eda61 in processCommandAndResetClient /home/ada/Project/valkey/src/networking.c:3808
    #7 0x55bc4c1eda61 in processPendingCommandAndInputBuffer /home/ada/Project/valkey/src/networking.c:3841
    #8 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:248
    #9 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:231
    #10 0x55bc4c2057c1 in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6451
    #11 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
    #12 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
    #13 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
    #14 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #15 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #16 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #17 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

previously allocated by thread T0 here:
    #0 0x7f858a320cb5 in malloc (/usr/lib/libasan.so.8+0x120cb5) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
    #1 0x55bc4c479589 in ztrymalloc_usable_internal /home/ada/Project/valkey/src/zmalloc.c:156
    #2 0x55bc4c479589 in valkey_malloc /home/ada/Project/valkey/src/zmalloc.c:185
    #3 0x55bc4c1cfd99 in createClient /home/ada/Project/valkey/src/networking.c:282
    #4 0x55bc4c1d5347 in acceptCommonHandler /home/ada/Project/valkey/src/networking.c:1835
    #5 0x55bc4c36312c in connSocketAcceptHandler /home/ada/Project/valkey/src/socket.c:333
    #6 0x55bc4bf6a6d4 in aeProcessEvents /home/ada/Project/valkey/src/ae.c:486
    #7 0x55bc4bf6a6d4 in aeMain /home/ada/Project/valkey/src/ae.c:543
    #8 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #9 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #10 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #11 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

SUMMARY: AddressSanitizer: heap-use-after-free /home/ada/Project/valkey/src/networking.c:6458 in processIOThreadsReadDone
Shadow bytes around the buggy address:
  0x7ce588be6100: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6180: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6200: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6280: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
  0x7ce588be6300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x7ce588be6380: fd[fd]fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6400: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6480: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6500: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6580: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
  0x7ce588be6600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==276399==ABORTING


Logged sanitizer errors (pid 276399):
=================================================================
==276399==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ce588be6388 at pc 0x55bc4c205b5f bp 0x7ffcdada6970 sp 0x7ffcdada6960
READ of size 8 at 0x7ce588be6388 thread T0
    #0 0x55bc4c205b5e in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6458
    #1 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
    #2 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
    #3 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
    #4 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #5 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #6 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #7 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

0x7ce588be6388 is located 8 bytes inside of 576-byte region [0x7ce588be6380,0x7ce588be65c0)
freed by thread T0 here:
    #0 0x7f858a31f79d  (/usr/lib/libasan.so.8+0x11f79d) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
    #1 0x55bc4c207064 in freeClient /home/ada/Project/valkey/src/networking.c:2168
    #2 0x55bc4c210ea8 in freeClient /home/ada/Project/valkey/src/networking.c:2055
    #3 0x55bc4c210ea8 in clientKillCommand /home/ada/Project/valkey/src/networking.c:5262
    #4 0x55bc4c3335cf in call /home/ada/Project/valkey/src/server.c:3882
    #5 0x55bc4c33becb in processCommand /home/ada/Project/valkey/src/server.c:4568
    #6 0x55bc4c1eda61 in processCommandAndResetClient /home/ada/Project/valkey/src/networking.c:3808
    #7 0x55bc4c1eda61 in processPendingCommandAndInputBuffer /home/ada/Project/valkey/src/networking.c:3841
    #8 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:248
    #9 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:231
    #10 0x55bc4c2057c1 in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6451
    #11 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
    #12 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
    #13 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
    #14 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #15 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #16 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #17 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

previously allocated by thread T0 here:
    #0 0x7f858a320cb5 in malloc (/usr/lib/libasan.so.8+0x120cb5) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
    #1 0x55bc4c479589 in ztrymalloc_usable_internal /home/ada/Project/valkey/src/zmalloc.c:156
    #2 0x55bc4c479589 in valkey_malloc /home/ada/Project/valkey/src/zmalloc.c:185
    #3 0x55bc4c1cfd99 in createClient /home/ada/Project/valkey/src/networking.c:282
    #4 0x55bc4c1d5347 in acceptCommonHandler /home/ada/Project/valkey/src/networking.c:1835
    #5 0x55bc4c36312c in connSocketAcceptHandler /home/ada/Project/valkey/src/socket.c:333
    #6 0x55bc4bf6a6d4 in aeProcessEvents /home/ada/Project/valkey/src/ae.c:486
    #7 0x55bc4bf6a6d4 in aeMain /home/ada/Project/valkey/src/ae.c:543
    #8 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
    #9 0x7f8589dee6c0  (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #10 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
    #11 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)

SUMMARY: AddressSanitizer: heap-use-after-free /home/ada/Project/valkey/src/networking.c:6458 in processIOThreadsReadDone
Shadow bytes around the buggy address:
  0x7ce588be6100: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6180: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6200: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6280: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
  0x7ce588be6300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x7ce588be6380: fd[fd]fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6400: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6480: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6500: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
  0x7ce588be6580: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
  0x7ce588be6600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==276399==ABORTING

[exception]: Executing test client: I/O error reading reply.
I/O error reading reply
    while executing
"[srv $level "client"] {*}$args"
    (procedure "r" line 7)
    invoked from within
"r ping"
    (procedure "stress_same_batch_client_kill_on_handled_clients" line 48)
    invoked from within
"stress_same_batch_client_kill_on_handled_clients"
    ("uplevel" body line 2)
    invoked from within
"uplevel 1 $code"
    (procedure "test" line 63)
    invoked from within
"test {ASAN canary for same-batch CLIENT KILL vs handled_clients post-pass} {
        stress_same_batch_client_kill_on_handled_clients
    }"
    ("uplevel" body line 2)
    invoked from within
"uplevel 1 $code "
    (procedure "start_server" line 2)
    invoked from within
"start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {io-threads 2 events-per-io-thread 0 use-exit-on-panic yes crash-..."
    (file "tests/unit/io-threads.tcl" line 157)
    invoked from within
"source $path"
    (procedure "execute_test_file" line 6)
    invoked from within
"execute_test_file $data"
    (procedure "test_client_main" line 10)
    invoked from within
"test_client_main $::test_server_port "

看上面的日志,这下就是复现成功了.

目前解决的手法就是:

git流程 backport操作
#

就是因为现在的代码逻辑已经和新的IO的逻辑不兼容了,所以我们要进行新的操作.就是把这部分代码合并进入旧版本就是9.0的版本中来处理.(就是cherry pick这样的操作.)

比如你需要知道自己当前的分支历史究竟做了什么修改:

$ git log --author="Ada-Church-Closure" --oneline origin/fix-rdma-io-threads

直接重建分支,然后做cherry pick:

cd /home/ada/Project/valkey
git fetch upstream

# 在「纯净 9.0」上重建分支(名字仍用 PR 分支名,方便直接更新 PR)
git checkout -B fix-rdma-io-threads upstream/9.0

git cherry-pick 3fc639514 d8f8b3991 0dabb3dc9 df67b5d6d 0eb377393 ecf0dd743 \
  36dbf89a9 3a43bd4f5 7f139ef5c 454b41518 48611b619 a3e370894 685ed1ec4 95b637d29

claude最后复盘
#

📋 问题总结

你在解决 Issue #3112: 当 RDMA 与 I/O 线程配合使用时,在高并发管道场景下(如 -P 32)会出现两个严重问题:

  1. 崩溃问题 (Crash)
  • 现象: 断言失败 c->cmd_queue.len == 0

  • 根本原因: 在 processIOThreadsReadDone 中,connUpdateState(c->conn) 在命令执行之前被调用。对于 RDMA,这会同步触发 CQ (Completion Queue),导致在cmd_queue 仍然有大量命令时重新进入解析逻辑,违反了断言条件

  1. 挂起问题 (Lost Wakeup)
  • 现象: 服务器完全卡死

  • 根本原因: I/O 线程由于批处理限制可能在 querybuf 中留下未解析的数据。由于 RDMA 的 CQ
    是边缘触发的,如果不主动排空缓冲区就返回事件循环,系统不会再次唤醒,导致死锁。

🔧 解决方案演进

初版方案 (commit 3fc6395)

在 src/networking.c:6378 的 processIOThreadsReadDone 函数中:

 // 1. 创建一个列表跟踪已处理的客户端
  list *handled_clients = listCreate();

  // 2. 延迟 connUpdateState 调用

  connUpdateState(c->conn);  // ❌ 移除过早的调用

  // 3. 收集处理过的客户端
  listAddNodeTail(handled_clients, c);

  // 4. 先批量执行命令队列
  processClientsCommandsBatch();

  // 5. 同步排空残留的 querybuf
  listRewind(handled_clients, &handled_li);
  while ((handled_ln = listNext(&handled_li))) {
      client *c = listNodeValue(handled_ln);
      if (c->querybuf && c->qb_pos < sdslen(c->querybuf)) {
          processPendingCommandAndInputBuffer(c);  // 🔑 关键:同步排空
      }
      connUpdateState(c->conn);  // ✅ 安全时机调用
  }

UAF 问题修复 (commit 0dabb3d → df67b5d)

  • 发现问题: @sarthakaggarwal97 指出直接存储客户端指针可能导致 UAF(Use-After-Free),因为 processPendingCommandAndInputBuffer 可能释放客户端

  • 尝试方案1: 使用客户端 ID 列表 + lookupClientByID(性能开销)

  • 最终方案: 修改 processClientsCommandsBatch 和 addCommandToBatchAndProcessIfFull,只在客户端存活时才加入 handled_clients 列表

TLS 兼容性修复 (commits 0eb3773 + ecf0dd7)

  • 问题: TLS 在 CONN_STATE_ACCEPTING 状态时也需要更新状态

  • 解决: 在 accept 状态下,如果是 TLS 连接,仍然调用 connUpdateState

代码简化 (commit 7f139ef)

  • 让 beforeNextClient 直接返回 int 而不是包装函数,简化逻辑

清理改进 (commits 48611b6 + a3e3708)

  • 移除不必要的 expire.h 包含

  • 其他小的代码格式修正

🎯 关键设计要点

  1. 边缘触发感知: RDMA 的 CQ 是边缘触发的,必须在返回事件循环前主动排空所有缓冲数据

  2. 重入安全: connUpdateState 对 RDMA 会同步触发 CQ,必须在命令队列为空时才能安全调用

  3. TCP/TLS 兼容: 这个改动对 TCP 是无害的(甚至略微减少 epoll 轮询),对 TLS 也正确处理了 accept 状态

  4. UAF 防护: 通过在正确的时机加入列表,避免操作已释放的客户端

✅ 验证方法

你的压力测试脚本非常棒:

  • 同时运行 4 个 TCP + 4 个 RDMA benchmark 进程

  • 使用高并发参数(-c 200 -P 64)

  • 随机杀死部分进程模拟网络中断

  • 服务器应该稳定运行,无崩溃、无挂起

📊 当前状态

  • ✅ 核心测试通过(test-ubuntu-io-threads, test-ubuntu-tls-io-threads)

  • ✅ RDMA 专项测试通过

  • ⚠️ 两个 CI 失败是 flaky tests(不稳定测试),与你的改动无关

  • 👍 获得 @pizhenwei 和 @zuiderkwast 的 LGTM(Looks Good To Me)

  • 🎯 已加入 Valkey 9.0 和 9.1 的待移植列表

💡 我的评价

这是一个非常高质量的修复:

  1. 问题分析透彻: 清楚识别了边缘触发和重入的本质问题

  2. 逐步优化: 从初版到最终版,通过 code review 不断改进

  3. 向后兼容: 对 TCP/TLS 零影响

  4. 测试完善: 提供了复现步骤和压力测试脚本

唯一还需要做的是等待 maintainer 处理那两个 flaky tests,然后就可以合并了!