跳过正文

valkey-benchmark端RDMA协议的丢失唤醒问题

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

https://github.com/valkey-io/valkey/issues/3345#issuecomment-4084382404

用户使用benchmark客户端GET有问题?初步分析就是丢失唤醒的问题.

删除代码比加上代码牛逼.

git清理
#

 git clean # 清理未跟踪的文件

git clean -nd src/unit

意思是:

  • n = -dry-run,只预演,不真正删除

  • d = 连未跟踪目录也一起考虑

  • src/unit = 只检查这个目录

所以这条命令的含义就是:

“告诉我,如果要清理 src/unit/ 里的未跟踪文件/目录,会删掉哪些,但先别真的删。”

git clean -fd src/unit

意思是:

  • f = -force,真的执行删除

  • d = 连未跟踪目录一起删

  • src/unit = 只删这个目录下的未跟踪内容

所以这条命令就是:

“把 src/unit/ 下的未跟踪文件和目录真的删掉。”

拉取最新主分支并且合并
#

 git fetch upstream # 获取最新上游的更改
 git stash push -m "......" # 暂存你的工作区
 git rebase upstream/unstable # 变基,相当于就是在远端重放你的修改
 git stash pop # 在你变基之后,相当于重放这你的修改

本次的workflow
#

如果有其他的进程占有了6379端口呢?
#

比如你可以看到:

 ~/Project/valkey fix/rdma-benchmark-lost-wakeup ❯ ps -ef | rg "[v]alkey-server"
 # 这里进行进程的管理
 root       23065    2181  0 Mar30 ?        00:00:00 sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379
 root       23070   23065  0 Mar30 ?        00:00:00 sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379
 root       23071   23070  0 Mar30 ?        00:02:50 ./src/valkey-server 127.0.0.1:6379
 # 可以观察到这个是一串父子进程,那么你可以直接kill掉父进程
 sudo kill 23071

workflow:

这里的构建的手法还是类似的:

# 解开当前进程的内存锁
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 distclean
# 编译一遍
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 $$
# 步骤 A:先用 SET 填充数据,验证 RDMA 通道畅通 (Issue 中说 SET 是正常的) 但是压测的话肯定会秒挂
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -t set -n 100000 -c 1 -d 102400 --rdma
# 步骤 B:执行 GET 触发 BUG (Issue 核心)
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -t get --rdma

# 这里可以完美复现,验证的非常可以.

关于整个机制的进一步理解
#

RDMA通知模型
#

TCP vs RDMA 的通知方式对比
#

TCP 很简单:一个 socket fd,内核通过 POLLIN(有数据可读)和 POLLOUT(可以写入)来告诉你。epoll_wait 一等就行。

RDMA 完全不一样。它用一个叫 Completion Queue (CQ) 的东西。任何操作(发送、接收)完成后,硬件往 CQ 里放一条记录(叫 Work Completion, ibv_wc)。

但你怎么知道 CQ 里有新的完成记录?RDMA 提供了一个 completion channel(**comp_channel**),它有一个 fd。当 CQ 有新完成记录时,这个 fd 变成"可读"。

关键机制是武装通知(arm notification)

ibv_req_notify_cq(cq, 0)   ← "武装":告诉硬件,下次有新完成记录时通知我                           						(往 comp_channel 的 fd 写一个事件)
ibv_get_cq_event(...)       ← "消费通知":从 fd 读取事件
ibv_ack_cq_events(...)      ← "确认"
ibv_poll_cq(cq, ...)        ← "取完成记录":从 CQ 里拿出实际的完成记录

标准模式是:消费事件 → 确认 → 重新武装 → 排空 CQ。重新武装放在排空之前,是为了保证"武装之后到达的新完成记录"一定能触发通知。而武装之前已经在 CQ 里的记录,靠排空来拿。

重新武装的意思就是重新把这个fd更换成可读的.

来看 libvalkey 的实现,它严格遵循了这个模式:

static int connRdmaHandleCq(valkeyContext *c) {
    RdmaContext *ctx = c->privctx;
    struct rdma_cm_id *cm_id = ctx->cm_id;
    struct ibv_cq *ev_cq = NULL;
    void *ev_ctx = NULL;
    struct ibv_wc wc = {0};
    valkeyRdmaCmd *cmd;
    int ret;

    //1. get事件---消费通知
    if (ibv_get_cq_event(ctx->comp_channel, &ev_cq, &ev_ctx) < 0) {
        if (errno != EAGAIN) {
            valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: get cq event failed");
            return VALKEY_ERR;
        }
		// 没有能get到的通知,就直接返回,此时不会做arm CQ,但是我想使用一种机制,保证每次都被arm了.
        return VALKEY_OK;
    }

    // 2. 确认机制
    ibv_ack_cq_events(ctx->cq, 1);
    // 3. 重新武装---在排空队列之前
    if (ibv_req_notify_cq(ev_cq, 0)) {
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: notify cq failed");
        return VALKEY_ERR;
    }

pollcq:
    // 4.取完成记录,这里就是排空队列了
    ret = ibv_poll_cq(ctx->cq, 1, &wc);
    if (ret < 0) {
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: poll cq failed");
        return VALKEY_ERR;
    } else if (ret == 0) {
        return VALKEY_OK;
    }

    if (wc.status != IBV_WC_SUCCESS) {
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: send/recv failed");
        return VALKEY_ERR;
    }
	// 根据不同的操作码进行操作.
    switch (wc.opcode) {
    case IBV_WC_RECV:
        cmd = (valkeyRdmaCmd *)(uintptr_t)wc.wr_id;
        if (connRdmaHandleRecv(c, ctx, cm_id, cmd, wc.byte_len) == VALKEY_ERR) {
            return VALKEY_ERR;
        }

        break;

    case IBV_WC_RECV_RDMA_WITH_IMM:
        cmd = (valkeyRdmaCmd *)(uintptr_t)wc.wr_id;
        if (connRdmaHandleRecvImm(ctx, cm_id, cmd, ntohl(wc.imm_data)) == VALKEY_ERR) {
            return VALKEY_ERR;
        }

        break;
    case IBV_WC_RDMA_WRITE:
        if (connRdmaHandleWrite(ctx, wc.byte_len) == VALKEY_ERR) {
            return VALKEY_ERR;
        }

        break;
    case IBV_WC_SEND:
        cmd = (valkeyRdmaCmd *)(uintptr_t)wc.wr_id;
        if (connRdmaHandleSend(cmd) == VALKEY_ERR) {
            return VALKEY_ERR;
        }

        break;
    default:
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: unexpected opcode");
        return VALKEY_ERR;
    }
	// 根据不同的opcode进行处理,直到此队列(CQ)为空
    goto pollcq;

    return VALKEY_OK;
}

注意两个关键点:

  • 无差别排空 CQ——不管是发送完成、接收完成还是控制消息,全部处理

  • comp_channel->fd 只有 POLLIN,没有 POLLOUT

意思就是我只知道什么时候有数据可读(就是rearm之后),但是不知道什么时候可写?

就是不管队列是空的或者是有数据的,我都判断不出来.

建立连接和注册fd:

static int valkeyRdmaEstablished(valkeyContext *c, struct rdma_cm_id *cm_id) {
    RdmaContext *ctx = c->privctx;

    /* it's time to tell redis we have already connected */
    c->flags |= VALKEY_CONNECTED;
    c->funcs = &valkeyContextRdmaFuncs;
    c->fd = ctx->comp_channel->fd;	// // ← benchmark 用的 fd 就是这个(注册到ae事件循环中的fd)

    return connRdmaRegisterRx(c, cm_id);
}

benchmark的RDMA是怎么处理的
#

对于TCP而言:

每一步都通过事件循环的 **epoll_wait** 来驱动。**AE_WRITABLE** 靠内核的 **POLLOUT****AE_READABLE** 靠内核的 **POLLIN** 就是这里都是经过事件的注册来解决.

对于RDMA来说,因为 RDMA 没有 POLLOUT,benchmark 做了特殊处理——直接调用 writeHandler,绕过事件循环:

static void resetClient(client c) {
    aeEventLoop *el = CLIENT_GET_EVENTLOOP(c);
    aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE);
    aeDeleteFileEvent(el, c->context->fd, AE_READABLE);
    if (config.ct == VALKEY_CONN_RDMA) {
        writeHandler(el, c->context->fd, c, 0); /* RDMA context always writable, but it can't be invoked by AE_WRITABLE (这里就是直接进行调用,不走事件循环) */
    } else {
        aeCreateFileEvent(el, c->context->fd, AE_WRITABLE, writeHandler, c);
    }
    c->written = 0;
    c->pending = config.pipeline * c->seqlen;
}

这里就是我可以使用POLLIN,相当于两个事件使用的手法是不一样的:

            } else {
                aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE); // 删除写事件
                aeCreateFileEvent(el, c->context->fd, AE_READABLE, readHandler, c); // 注册读事件
                return;

然后回到事件循环,等 comp_channel fd 变成可读(CQ 有完成通知)。

我们再分析一下TCP的处理流程
#

createClient → 注册 AE_WRITABLE(writeHandler)
                    ↓ 事件循环触发
               writeHandler → write() → 注册 AE_READABLE(readHandler)
                                              ↓ 事件循环触发
                                         readHandler → read() → clientDone → resetClient
                                                                                   ↓
                                                                     注册 AE_WRITABLE(writeHandler)
                                                                              ↓ 事件循环触发
                                                                         writeHandler → ...

这里都是注册事件的循环处理,都通过内核的事件循环.epoll_wait.

Bug是如何产生的
#

writeHandler 通过调用链 cliWriteConnvalkeyBufferWritevalkeyRdmaWrite 来发送数据。看 valkeyRdmaWrite 的第一件事:

static ssize_t valkeyRdmaWrite(valkeyContext *c) {
    RdmaContext *ctx = c->privctx;
    struct rdma_cm_id *cm_id = ctx->cm_id;
    size_t data_len = sdslen(c->obuf);
    long timed, end;
    uint32_t towrite, wrote = 0;
    size_t ret;

    if (valkeyCommandTimeoutMsec(c, &timed)) {
        return VALKEY_ERR;
    }

    end = vk_msec_now() + timed;

pollcq:
    if (connRdmaHandleCq(c) == VALKEY_ERR) { // <--- 写数据之前先排空CQ,然后再发送数据
        return VALKEY_ERR;
    }
    ......

connRdmaHandleCq 排空 CQ 时不区分事件类型——它会处理所有完成记录,包括 IBV_WC_RECV_RDMA_WITH_IMM(服务器发来的数据)。处理接收事件时会更新 ctx->rx_offset

static int connRdmaHandleRecvImm(RdmaContext *ctx, struct rdma_cm_id *cm_id, valkeyRdmaCmd *cmd, uint32_t byte_len) {
    assert(byte_len + ctx->rx_offset <= ctx->recv_length);
    ctx->rx_offset += byte_len; // 此时数据已经在buf内部了
	// 这个就是处理服务器发过来的数据
    return rdmaPostRecv(ctx, cm_id, cmd);
}

那么我们以一个get请求为例,来分析一个死锁的场景:

第 N 轮请求-响应完成
    ↓
readHandler:处理完响应,pending=0
    ↓
clientDone → resetClient
    ↓
resetClient 删除 AE_READABLE
    ↓
resetClient 直接调用 writeHandler       ← 此时没有任何事件注册在 fd 上
    ↓
writeHandler → cliWriteConn → valkeyRdmaWrite
    ↓
valkeyRdmaWrite 先调用 connRdmaHandleCq   ← 关键!
    ↓
connRdmaHandleCq:
    1. ibv_get_cq_event()  ← 消费了 comp_channel 上的通知
    2. ibv_ack_cq_events() ← 确认
    3. ibv_req_notify_cq() ← 重新武装
    4. ibv_poll_cq() 循环  ← 排空 CQ,如果服务器的响应恰好在 CQ 里,
                              rx_offset 被更新,数据放入了 recv_buf
    ↓
valkeyRdmaWrite 发送第 N+1 轮的 GET 请求
    ↓
writeHandler 注册 AE_READABLE(readHandler)
    ↓
回到 ae 事件循环,epoll_wait()...

简单来说就是拿到了缓冲区但是没办法处理了.

我们一般怎么使用char**
#

如果你想在函数内部修改一个 char* 变量(即改变这个指针的指向,比如让它指向一块新分配的内存),你就必须传入这个指针的地址,也就是 char**

\#include <stdio.h>
\#include <stdlib.h>

int main() {
    char *my_buf = NULL;  // 这是一个 char*,目前指向空
    size_t size = 0;

    // 我们传入 &my_buf,类型就是 char**
    ssize_t chars_read = getline(&my_buf, &size, stdin);

    if (chars_read != -1) {
        printf("Read: %s", my_buf);
    }

    free(my_buf); // 调用者负责释放内存
    return 0;
}

内存视角:

调用者的栈区                 getline 函数的栈区            堆区 (Heap)
[ &my_buf ] -------------> [ lineptr ]
  (char**)                   (char**)
                               |
                               v
[ my_buf ]  <---(解引用并赋值)---*lineptr  -------------> [ "This is a line...\0" ]
  (char*)                                                (动态分配的真实缓冲区)

或者是一个字符串指针数组.

有时候jemalloc会报错,需要手动重新构建
#

debug.c:342:20: error: implicit declaration of function ‘je_mallctl’; did you mean ‘mallctl’? [-Wimplicit-function-declaration]
  342 |         if ((ret = je_mallctl(objectGetVal(argv[0]), &old, &zz, argc > 1 ? &val : NULL, argc > 1 ? sz : 0))) {
      |                    ^~~~~~~~~~
      |                    mallctl
In file included from dict.h:38,
                 from server.h:68,
                 from debug.c:30:
debug.c: In function ‘debugCommand’:

手动进行构建:

 cd deps/jemalloc
 ./configure
 make
 cd ../..
 make BUILD_RDMA=yes USE_FAST_FLOAT=yes

Burst LostWakeup
#

1.实际上经过修改,不会再产生死锁,就是./src/valkey-benchmark --rdma -h 10.0.0.1 -t get -n 100000 -c 1 这个命令最终一定会执行完毕(不会卡住),但是中间的现象是会出现卡顿,就是RPS突然有一段时间为0,然后又突然非常高,处理了一段时间,然后又会卡住,之后又会处理一段时间.

那么怎么分析这个问题?

就是我每次都arm CQ队列试试.

我们尝试解决这个问题,把之前rdma.c的代码这样来进行处理:

static int connRdmaHandleCq(valkeyContext *c) {
    RdmaContext *ctx = c->privctx;
    struct rdma_cm_id *cm_id = ctx->cm_id;
    struct ibv_cq *ev_cq = NULL;
    void *ev_ctx = NULL;
    struct ibv_wc wc = {0};
    valkeyRdmaCmd *cmd;
    int ret;
	// 这里修改一下逻辑,不管是否是get成功,我们都需要重新武装,然后pollcq
    if (ibv_get_cq_event(ctx->comp_channel, &ev_cq, &ev_ctx) < 0) {
        if (errno != EAGAIN) {
            valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: get cq event failed");
            return VALKEY_ERR;
        }

        ev_cq = ctx->cq;
    } else {
        ibv_ack_cq_events(ctx->cq, 1);
    }

    if (ibv_req_notify_cq(ev_cq, 0)) {
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: notify cq failed");
        return VALKEY_ERR;
    }

pollcq:
    ret = ibv_poll_cq(ctx->cq, 1, &wc);
    if (ret < 0) {
        valkeySetError(c, VALKEY_ERR_OTHER, "RDMA: poll cq failed");
        return VALKEY_ERR;
    } else if (ret == 0) {
        return VALKEY_OK;
    }

2.这样修改之后,在执行./src/valkey-benchmark -h 10.0.0.1 -p 6379 -t get --rdma的时候不会出现什么问题,速度很快并且不会周期性的停止,单次运行完毕不会出现什么问题,但是一旦强制中断,server就会报错,找一下root cause.

错误日志:

65182:M 08 Apr 2026 22:41:52.232 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
65182:M 08 Apr 2026 22:41:52.232 * oO0OoO0OoO0Oo Valkey is starting oO0OoO0OoO0Oo
65182:M 08 Apr 2026 22:41:52.232 * Valkey version=255.255.255, bits=64, commit=e041a64b, modified=1, pid=65182, just started
65182:M 08 Apr 2026 22:41:52.232 * Configuration loaded
65182:M 08 Apr 2026 22:41:52.263 * monotonic clock: X86 TSC @ 2419.21 ticks/us
                .+^+.
            .+#########+.
        .+########+########+.           Valkey 255.255.255 (e041a64b/1) 64 bit
    .+########+'     '+########+.
 .########+'     .+.     '+########.    Running in standalone mode
 |####+'     .+#######+.     '+####|    Port: 6379
 |###|   .+###############+.   |###|    PID: 65182
 |###|   |#####*'' ''*#####|   |###|
 |###|   |####'  .-.  '####|   |###|
 |###|   |###(  (@@@)  )###|   |###|          https://valkey.io
 |###|   |####.  '-'  .####|   |###|
 |###|   |#####*.   .*#####|   |###|
 |###|   '+#####|   |#####+'   |###|
 |####+.     +##|   |#+'     .+####|
 '#######+   |##|        .+########'
    '+###|   |##|    .+########+'
        '|   |####+########+'
             +#########+'
                '+v+'

65182:M 08 Apr 2026 22:41:52.266 * Module 'lua' loaded from libvalkeylua.so
65182:M 08 Apr 2026 22:41:52.266 * Server initialized
65182:M 08 Apr 2026 22:41:52.266 * Cleaning slot migration log in anticipation of a load operation.
65182:M 08 Apr 2026 22:41:52.266 * Loading RDB produced by Valkey version 255.255.255
65182:M 08 Apr 2026 22:41:52.266 * RDB age 85299 seconds
65182:M 08 Apr 2026 22:41:52.266 * RDB memory usage when created 1.03 Mb
65182:M 08 Apr 2026 22:41:52.267 * Done loading RDB, keys loaded: 1, keys expired: 0, all fields expired hashes: 0.
65182:M 08 Apr 2026 22:41:52.267 * DB loaded from disk: 0.002 seconds
65182:M 08 Apr 2026 22:41:52.267 * Ready to accept connections tcp
65182:M 08 Apr 2026 22:41:52.267 * Ready to accept connections rdma


=== VALKEY BUG REPORT START: Cut & paste starting from here ===
65182:M 08 Apr 2026 22:51:41.980 # valkey 255.255.255 crashed by signal: 11, si_code: 1
65182:M 08 Apr 2026 22:51:41.980 # Accessing address: (nil)
65182:M 08 Apr 2026 22:51:41.980 # Crashed running the instruction at: 0x557150b794dd

------ STACK TRACE ------
EIP:
./src/valkey-server 127.0.0.1:6379(listUnlinkNode+0xd) [0x557150b794dd]

65182 valkey-server *
/usr/lib/libc.so.6(+0x3e2d0) [0x7fdaddd402d0]
./src/valkey-server 127.0.0.1:6379(listUnlinkNode+0xd) [0x557150b794dd]
./src/valkey-server 127.0.0.1:6379(+0x1b1eb6) [0x557150c8aeb6]
./src/valkey-server 127.0.0.1:6379(beforeSleep+0x82) [0x557150cc2ec2]
./src/valkey-server 127.0.0.1:6379(aeMain+0x3f) [0x557150b78e5f]
./src/valkey-server 127.0.0.1:6379(main+0x54f) [0x557150b6823f]
/usr/lib/libc.so.6(+0x276c1) [0x7fdaddd296c1]
/usr/lib/libc.so.6(__libc_start_main+0x89) [0x7fdaddd297f9]
./src/valkey-server 127.0.0.1:6379(_start+0x25) [0x557150b69e25]

65188 bio_aof
/usr/lib/libc.so.6(+0x9ef32) [0x7fdaddda0f32]
/usr/lib/libc.so.6(+0x9339c) [0x7fdaddd9539c]
/usr/lib/libc.so.6(+0x9368c) [0x7fdaddd9568c]
/usr/lib/libc.so.6(pthread_cond_wait+0x14e) [0x7fdaddd97e5e]
./src/valkey-server 127.0.0.1:6379(mutexQueuePop+0x5b) [0x557150c428cb]
./src/valkey-server 127.0.0.1:6379(bioProcessBackgroundJobs+0xee) [0x557150b88bce]
/usr/lib/libc.so.6(+0x9697a) [0x7fdaddd9897a]
/usr/lib/libc.so.6(+0x11a2bc) [0x7fdadde1c2bc]

65187 bio_close_file
/usr/lib/libc.so.6(+0x9ef32) [0x7fdaddda0f32]
/usr/lib/libc.so.6(+0x9339c) [0x7fdaddd9539c]
/usr/lib/libc.so.6(+0x9368c) [0x7fdaddd9568c]
/usr/lib/libc.so.6(pthread_cond_wait+0x14e) [0x7fdaddd97e5e]
./src/valkey-server 127.0.0.1:6379(mutexQueuePop+0x5b) [0x557150c428cb]
./src/valkey-server 127.0.0.1:6379(bioProcessBackgroundJobs+0xee) [0x557150b88bce]
/usr/lib/libc.so.6(+0x9697a) [0x7fdaddd9897a]
/usr/lib/libc.so.6(+0x11a2bc) [0x7fdadde1c2bc]

65190 bio_rdb_save
/usr/lib/libc.so.6(+0x9ef32) [0x7fdaddda0f32]
/usr/lib/libc.so.6(+0x9339c) [0x7fdaddd9539c]
/usr/lib/libc.so.6(+0x9368c) [0x7fdaddd9568c]
/usr/lib/libc.so.6(pthread_cond_wait+0x14e) [0x7fdaddd97e5e]
./src/valkey-server 127.0.0.1:6379(mutexQueuePop+0x5b) [0x557150c428cb]
./src/valkey-server 127.0.0.1:6379(bioProcessBackgroundJobs+0xee) [0x557150b88bce]
/usr/lib/libc.so.6(+0x9697a) [0x7fdaddd9897a]
/usr/lib/libc.so.6(+0x11a2bc) [0x7fdadde1c2bc]

65191 bio_tls_reload
/usr/lib/libc.so.6(+0x9ef32) [0x7fdaddda0f32]
/usr/lib/libc.so.6(+0x9339c) [0x7fdaddd9539c]
/usr/lib/libc.so.6(+0x9368c) [0x7fdaddd9568c]
/usr/lib/libc.so.6(pthread_cond_wait+0x14e) [0x7fdaddd97e5e]
./src/valkey-server 127.0.0.1:6379(mutexQueuePop+0x5b) [0x557150c428cb]
./src/valkey-server 127.0.0.1:6379(bioProcessBackgroundJobs+0xee) [0x557150b88bce]
/usr/lib/libc.so.6(+0x9697a) [0x7fdaddd9897a]
/usr/lib/libc.so.6(+0x11a2bc) [0x7fdadde1c2bc]

65189 bio_lazy_free
/usr/lib/libc.so.6(+0x9ef32) [0x7fdaddda0f32]
/usr/lib/libc.so.6(+0x9339c) [0x7fdaddd9539c]
/usr/lib/libc.so.6(+0x9368c) [0x7fdaddd9568c]
/usr/lib/libc.so.6(pthread_cond_wait+0x14e) [0x7fdaddd97e5e]
./src/valkey-server 127.0.0.1:6379(mutexQueuePop+0x5b) [0x557150c428cb]
./src/valkey-server 127.0.0.1:6379(bioProcessBackgroundJobs+0xee) [0x557150b88bce]
/usr/lib/libc.so.6(+0x9697a) [0x7fdaddd9897a]
/usr/lib/libc.so.6(+0x11a2bc) [0x7fdadde1c2bc]

6/6 expected stacktraces.

------ STACK TRACE DONE ------

------ REGISTERS ------
65182:M 08 Apr 2026 22:51:41.982 #
RAX:0000000000000011 RBX:00007fdadd84eea0
RCX:0000000000003b10 RDX:0000000000003b10
RDI:00007fdadd83d420 RSI:0000000000000000
RBP:00007ffd483bdd10 RSP:00007ffd483bdd10
R8 :0000000000000000 R9 :0000000000000000
R10:0000000000000000 R11:00007fdaddc11700
R12:00007fdadd91d1f8 R13:0000000000000002
R14:0000000000000000 R15:00007fdadd83d420
RIP:0000557150b794dd EFL:0000000000010206
CSGSFS:002b000000000033
65182:M 08 Apr 2026 22:51:41.982 * hide-user-data-from-log is on, skip logging stack content to avoid spilling user data.

------ INFO OUTPUT ------
# Server
redis_version:7.2.4
server_name:valkey
valkey_version:255.255.255
valkey_release_stage:dev
redis_git_sha1:e041a64b
redis_git_dirty:1
redis_build_id:91cde172b5faf4da
server_mode:standalone
os:Linux 6.19.9-arch1-1 x86_64
arch_bits:64
monotonic_clock:X86 TSC @ 2419.21 ticks/us
multiplexing_api:epoll
gcc_version:15.2.1
process_id:65182
process_supervised:no
run_id:743545a423047bbedd292c4829a02ee55c05ae92
tcp_port:6379
server_time_usec:1775659901980057
uptime_in_seconds:589
uptime_in_days:0
hz:10
configured_hz:10
clients_hz:10
lru_clock:14052221
executable:/home/ada/Project/valkey/./src/valkey-server
config_file:/home/ada/Project/valkey/valkey.conf
io_threads_active:0
availability_zone:
listener0:name=tcp,bind=127.0.0.1,bind=-::1,port=6379
listener3:name=rdma,bind=10.0.0.1,port=6379

# TLS
tls_server_cert_serial:none
tls_server_cert_expires_in_seconds:0
tls_client_cert_serial:none
tls_client_cert_expires_in_seconds:0
tls_ca_cert_serial:none
tls_ca_cert_expires_in_seconds:0

# Clients
connected_clients:28
cluster_connections:0
maxclients:10000
client_recent_max_input_buffer:0
client_recent_max_output_buffer:0
blocked_clients:0
tracking_clients:0
pubsub_clients:0
watching_clients:0
clients_in_timeout_table:0
total_watched_keys:0
total_blocking_keys:0
total_blocking_keys_on_nokey:0
paused_reason:none
paused_actions:none
paused_timeout_milliseconds:0

# Memory
used_memory:1471272
used_memory_human:1.40M
used_memory_rss:330694656
used_memory_rss_human:315.38M
used_memory_peak:1931320
used_memory_peak_human:1.84M
used_memory_peak_perc:76.18%
used_memory_overhead:1314736
used_memory_startup:913872
used_memory_dataset:156536
used_memory_dataset_perc:28.08%
allocator_allocated:2030536
allocator_active:2326528
allocator_resident:9691136
allocator_muzzy:0
total_system_memory:16435552256
total_system_memory_human:15.31G
used_memory_lua:33792
used_memory_vm_eval:33792
used_memory_lua_human:33.00K
used_memory_scripts_eval:80
number_of_cached_scripts:0
number_of_functions:0
number_of_libraries:0
used_memory_vm_functions:35840
used_memory_vm_total:69632
used_memory_vm_total_human:68.00K
used_memory_functions:512
used_memory_scripts:592
used_memory_scripts_human:592B
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
allocator_frag_ratio:1.15
allocator_frag_bytes:295992
allocator_rss_ratio:4.17
allocator_rss_bytes:7364608
rss_overhead_ratio:34.12
rss_overhead_bytes:321003520
mem_fragmentation_ratio:178.37
mem_fragmentation_bytes:328840704
mem_not_counted_for_evict:0
mem_replication_backlog:0
mem_total_replication_buffers:0
mem_replicas_repl_buffer:0
mem_clients_slaves:0
mem_clients_normal:399872
mem_cluster_links:0
mem_cluster_slot_import:0
mem_cluster_slot_export:0
mem_aof_buffer:0
mem_allocator:jemalloc-5.3.0
mem_overhead_db_hashtable_rehashing:0
active_defrag_running:0
lazyfree_pending_objects:0
lazyfreed_objects:0

# Persistence
loading:0
async_loading:0
current_cow_peak:0
current_cow_size:0
current_cow_size_age:0
current_fork_perc:0.00
current_save_keys_processed:0
current_save_keys_total:0
rdb_changes_since_last_save:0
rdb_bgsave_in_progress:0
rdb_last_save_time:1775659312
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:-1
rdb_current_bgsave_time_sec:-1
rdb_saves:0
rdb_last_cow_size:0
rdb_last_load_keys_expired:0
rdb_last_load_keys_loaded:1
aof_enabled:0
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:-1
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok
aof_rewrites:0
aof_rewrites_consecutive_failures:0
aof_last_write_status:ok
aof_last_cow_size:0
module_fork_in_progress:0
module_fork_last_cow_size:0
slot_migration_fork_in_progress:0

# Stats
total_connections_received:169
total_commands_processed:221202
instantaneous_ops_per_sec:12469
total_net_input_bytes:7963261
total_net_output_bytes:22652011003
total_net_repl_input_bytes:0
total_net_repl_output_bytes:0
total_net_cluster_slot_import_bytes:0
total_net_cluster_slot_export_bytes:0
instantaneous_input_kbps:438.38
instantaneous_output_kbps:1246618.12
instantaneous_input_repl_kbps:0.00
instantaneous_output_repl_kbps:0.00
rejected_connections:0
sync_full:0
sync_partial_ok:0
sync_partial_err:0
expired_keys:0
expired_fields:0
expired_stale_perc:0.00
expired_keys_with_volatile_items_stale_perc:0.00
expired_time_cap_reached_count:0
expire_cycle_cpu_milliseconds:9
evicted_keys:0
evicted_clients:0
evicted_scripts:0
total_eviction_exceeded_time:0
current_eviction_exceeded_time:0
keyspace_hits:221191
keyspace_misses:0
pubsub_channels:0
pubsub_patterns:0
pubsubshard_channels:0
latest_fork_usec:0
total_forks:0
migrate_cached_sockets:0
slave_expires_tracked_keys:0
active_defrag_hits:0
active_defrag_misses:0
active_defrag_key_hits:0
active_defrag_key_misses:0
total_active_defrag_time:0
current_active_defrag_time:0
tracking_total_keys:0
tracking_total_items:0
tracking_total_prefixes:0
unexpected_error_replies:0
total_error_replies:0
dump_payload_sanitizations:0
total_reads_processed:221337
total_writes_processed:242728
io_threaded_reads_processed:0
io_threaded_writes_processed:0
io_threaded_freed_objects:0
io_threaded_accept_processed:0
io_threaded_poll_processed:0
io_threaded_total_prefetch_batches:0
io_threaded_total_prefetch_entries:0
client_query_buffer_limit_disconnections:0
client_output_buffer_limit_disconnections:0
reply_buffer_shrinks:104
reply_buffer_expands:0
eventloop_cycles:319079
eventloop_duration_sum:12139821
eventloop_duration_cmd_sum:72627
instantaneous_eventloop_cycles_per_sec:123278
instantaneous_eventloop_duration_usec:54
acl_access_denied_auth:0
acl_access_denied_cmd:0
acl_access_denied_key:0
acl_access_denied_channel:0
acl_access_denied_tls_cert:0
acl_access_denied_db:0

# Replication
role:master
connected_slaves:0
replicas_waiting_psync:0
master_failover_state:no-failover
master_replid:fe7364c15c9f10096f43baa437609ef6583cdca6
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:0
second_repl_offset:-1
repl_backlog_active:0
repl_backlog_size:10485760
repl_backlog_first_byte_offset:0
repl_backlog_histlen:0

# CPU
used_cpu_sys:6.023293
used_cpu_user:6.375262
used_cpu_sys_children:0.000000
used_cpu_user_children:0.000000
used_cpu_sys_main_thread:6.024327
used_cpu_user_main_thread:6.372343
used_active_time_main_thread:11.080413

# Modules
module:name=lua,ver=1,api=1,filters=0,usedby=[],using=[],options=[handle-repl-async-load|handle-atomic-slot-migration]

# Commandstats
cmdstat_config|get:calls=11,usec=96,usec_per_call=8.73,rejected_calls=0,failed_calls=0
cmdstat_get:calls=221191,usec=72539,usec_per_call=0.33,rejected_calls=0,failed_calls=0

# Errorstats

# Latencystats
latency_percentiles_usec_config|get:p50=8.031,p99=19.071,p99.9=19.071
latency_percentiles_usec_get:p50=0.001,p99=1.003,p99.9=3.007

# Cluster
cluster_enabled:0

# Cluster Info

# Scripting Engines
engines_count:1
engines_total_used_memory:69632
engines_total_memory_overhead:56
engine_0:name=LUA,module=lua,abi_version=4,used_memory=69632,memory_overhead=56

# Keyspace
db0:keys=1,expires=0,avg_ttl=0,keys_with_volatile_items=0

------ CLIENT LIST OUTPUT ------
id=127 addr=10.0.0.1:42020 laddr=10.0.0.1:6379 fd=137 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15192 tot-net-out=43217442 tot-cmds=422
id=128 addr=10.0.0.1:46841 laddr=10.0.0.1:6379 fd=138 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15120 tot-net-out=43012620 tot-cmds=420
id=129 addr=10.0.0.1:55292 laddr=10.0.0.1:6379 fd=139 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15372 tot-net-out=43729497 tot-cmds=427
id=130 addr=10.0.0.1:45720 laddr=10.0.0.1:6379 fd=140 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15192 tot-net-out=43217442 tot-cmds=422
id=131 addr=10.0.0.1:43029 laddr=10.0.0.1:6379 fd=141 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15192 tot-net-out=43217442 tot-cmds=422
id=132 addr=10.0.0.1:41118 laddr=10.0.0.1:6379 fd=142 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15300 tot-net-out=43524675 tot-cmds=425
id=133 addr=10.0.0.1:56220 laddr=10.0.0.1:6379 fd=143 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15300 tot-net-out=43524675 tot-cmds=425
id=134 addr=10.0.0.1:34762 laddr=10.0.0.1:6379 fd=144 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15372 tot-net-out=43729497 tot-cmds=427
id=135 addr=10.0.0.1:34373 laddr=10.0.0.1:6379 fd=145 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15516 tot-net-out=44139141 tot-cmds=431
id=136 addr=10.0.0.1:55394 laddr=10.0.0.1:6379 fd=146 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=35 oll=0 omem=0 tot-mem=17024 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15120 tot-net-out=42991616 tot-cmds=420
id=137 addr=10.0.0.1:47348 laddr=10.0.0.1:6379 fd=147 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15192 tot-net-out=43217442 tot-cmds=422
id=138 addr=10.0.0.1:46385 laddr=10.0.0.1:6379 fd=148 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=35 oll=0 omem=0 tot-mem=17024 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15516 tot-net-out=44040192 tot-cmds=431
id=139 addr=10.0.0.1:47577 laddr=10.0.0.1:6379 fd=149 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=14976 tot-net-out=42602976 tot-cmds=416
id=140 addr=10.0.0.1:59830 laddr=10.0.0.1:6379 fd=150 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15444 tot-net-out=43934319 tot-cmds=429
id=141 addr=10.0.0.1:42838 laddr=10.0.0.1:6379 fd=151 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15192 tot-net-out=43217442 tot-cmds=422
id=142 addr=10.0.0.1:41475 laddr=10.0.0.1:6379 fd=152 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=16384 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15372 tot-net-out=43729497 tot-cmds=427
id=143 addr=10.0.0.1:41510 laddr=10.0.0.1:6379 fd=153 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=16384 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15372 tot-net-out=43729497 tot-cmds=427
id=144 addr=10.0.0.1:48851 laddr=10.0.0.1:6379 fd=154 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=35 oll=0 omem=0 tot-mem=17024 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15120 tot-net-out=42991616 tot-cmds=420
id=145 addr=10.0.0.1:60977 laddr=10.0.0.1:6379 fd=155 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15408 tot-net-out=43831908 tot-cmds=428
id=146 addr=10.0.0.1:52792 laddr=10.0.0.1:6379 fd=156 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15264 tot-net-out=43422264 tot-cmds=424
id=147 addr=10.0.0.1:37615 laddr=10.0.0.1:6379 fd=157 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=35 oll=0 omem=0 tot-mem=17024 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15516 tot-net-out=44040192 tot-cmds=431
id=148 addr=10.0.0.1:34941 laddr=10.0.0.1:6379 fd=158 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15156 tot-net-out=43115031 tot-cmds=421
id=149 addr=10.0.0.1:34226 laddr=10.0.0.1:6379 fd=159 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=16384 rbp=35 obl=0 oll=0 omem=0 tot-mem=17024 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15228 tot-net-out=43319853 tot-cmds=423
id=122 addr=10.0.0.1:33288 laddr=10.0.0.1:6379 fd=132 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=35 obl=35 oll=0 omem=0 tot-mem=1664 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15516 tot-net-out=44040192 tot-cmds=431
id=123 addr=10.0.0.1:47385 laddr=10.0.0.1:6379 fd=133 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=35 obl=0 oll=0 omem=0 tot-mem=1664 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15300 tot-net-out=43524675 tot-cmds=425
id=124 addr=10.0.0.1:47596 laddr=10.0.0.1:6379 fd=134 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=35 obl=0 oll=0 omem=0 tot-mem=1664 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15300 tot-net-out=43524675 tot-cmds=425
id=125 addr=10.0.0.1:35487 laddr=10.0.0.1:6379 fd=135 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=35 obl=0 oll=0 omem=0 tot-mem=1664 events=r cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15120 tot-net-out=43012620 tot-cmds=420
id=126 addr=10.0.0.1:37275 laddr=10.0.0.1:6379 fd=136 name=*redacted* age=1 idle=0 flags=N capa= db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0 qbuf=0 qbuf-free=0 argv-mem=0 multi-mem=0 rbs=1024 rbp=35 obl=35 oll=0 omem=0 tot-mem=1664 events=rw cmd=get user=*redacted* redir=-1 resp=2 lib-name= lib-ver= tot-net-in=15120 tot-net-out=42991616 tot-cmds=420

------ MODULES INFO OUTPUT ------

------ CONFIG DEBUG OUTPUT ------
proto-max-bulk-len 512mb
sanitize-dump-payload no
repl-diskless-sync yes
import-mode no
list-compress-depth 0
client-query-buffer-limit 1gb
lazyfree-lazy-eviction yes
activedefrag no
slave-read-only yes
io-threads 1
replica-read-only yes
debug-context ""
repl-diskless-load disabled
dual-channel-replication-enabled no
lazyfree-lazy-user-flush yes
lazyfree-lazy-expire yes
lazyfree-lazy-user-del yes
lazyfree-lazy-server-del yes

------ FAST MEMORY TEST ------
65182:M 08 Apr 2026 22:51:41.983 # Bio worker thread #0 terminated
65182:M 08 Apr 2026 22:51:41.983 # Bio worker thread #1 terminated
65182:M 08 Apr 2026 22:51:41.983 # Bio worker thread #2 terminated
65182:M 08 Apr 2026 22:51:41.983 # Bio worker thread #3 terminated
65182:M 08 Apr 2026 22:51:41.983 # Bio worker thread #4 terminated
*** Preparing to test memory region 557150ede000 (2355200 bytes)
*** Preparing to test memory region 55717f5e2000 (405504 bytes)
*** Preparing to test memory region 7fdac95fc000 (1048576 bytes)
*** Preparing to test memory region 7fdac9830000 (1048576 bytes)
*** Preparing to test memory region 7fdac99b3000 (1048576 bytes)
*** Preparing to test memory region 7fdac9ab5000 (1048576 bytes)
*** Preparing to test memory region 7fdac9eed000 (1048576 bytes)
*** Preparing to test memory region 7fdaca1a5000 (1048576 bytes)
*** Preparing to test memory region 7fdaca5dd000 (1048576 bytes)
*** Preparing to test memory region 7fdaca6df000 (1048576 bytes)
*** Preparing to test memory region 7fdacac97000 (1048576 bytes)
*** Preparing to test memory region 7fdacad99000 (1048576 bytes)
*** Preparing to test memory region 7fdacb486000 (1048576 bytes)
*** Preparing to test memory region 7fdacb68a000 (1048576 bytes)
*** Preparing to test memory region 7fdacb8c1000 (1048576 bytes)
*** Preparing to test memory region 7fdacb9c3000 (1048576 bytes)
*** Preparing to test memory region 7fdacbefa000 (1048576 bytes)
*** Preparing to test memory region 7fdacc07d000 (1048576 bytes)
*** Preparing to test memory region 7fdacc434000 (1048576 bytes)
*** Preparing to test memory region 7fdacc536000 (1048576 bytes)
*** Preparing to test memory region 7fdaccba5000 (1048576 bytes)
*** Preparing to test memory region 7fdaccca7000 (1048576 bytes)
*** Preparing to test memory region 7fdacd05e000 (1048576 bytes)
*** Preparing to test memory region 7fdacd160000 (1048576 bytes)
*** Preparing to test memory region 7fdacd718000 (1048576 bytes)
*** Preparing to test memory region 7fdacd81a000 (1048576 bytes)
*** Preparing to test memory region 7fdacdcd3000 (1048576 bytes)
*** Preparing to test memory region 7fdacdf07000 (1048576 bytes)
*** Preparing to test memory region 7fdace330000 (65536 bytes)
*** Preparing to test memory region 7fdace342000 (1048576 bytes)
*** Preparing to test memory region 7fdace444000 (1048576 bytes)
*** Preparing to test memory region 7fdace7e9000 (65536 bytes)
*** Preparing to test memory region 7fdace8a6000 (65536 bytes)
*** Preparing to test memory region 7fdace97b000 (1048576 bytes)
*** Preparing to test memory region 7fdaceafe000 (1048576 bytes)
*** Preparing to test memory region 7fdacec81000 (65536 bytes)
*** Preparing to test memory region 7fdacefb7000 (1048576 bytes)
*** Preparing to test memory region 7fdacf13a000 (65536 bytes)
*** Preparing to test memory region 7fdacf1ee000 (1048576 bytes)
*** Preparing to test memory region 7fdacf5f3000 (1048576 bytes)
*** Preparing to test memory region 7fdacf6f4000 (2621440 bytes)
*** Preparing to test memory region 7fdacf975000 (65536 bytes)
*** Preparing to test memory region 7fdacf9a8000 (1048576 bytes)
*** Preparing to test memory region 7fdacfd2c000 (65536 bytes)
*** Preparing to test memory region 7fdacfe61000 (1048576 bytes)
*** Preparing to test memory region 7fdad01e5000 (65536 bytes)
*** Preparing to test memory region 7fdad0320000 (65536 bytes)
*** Preparing to test memory region 7fdad0332000 (65536 bytes)
*** Preparing to test memory region 7fdad0365000 (65536 bytes)
*** Preparing to test memory region 7fdad0398000 (1048576 bytes)
*** Preparing to test memory region 7fdad051b000 (1048576 bytes)
*** Preparing to test memory region 7fdad069e000 (65536 bytes)
*** Preparing to test memory region 7fdad06d1000 (1048576 bytes)
*** Preparing to test memory region 7fdad09d4000 (1048576 bytes)
*** Preparing to test memory region 7fdad0c08000 (1048576 bytes)
*** Preparing to test memory region 7fdad1010000 (65536 bytes)
*** Preparing to test memory region 7fdad1145000 (1048576 bytes)
*** Preparing to test memory region 7fdad14c9000 (65536 bytes)
*** Preparing to test memory region 7fdad1583000 (65536 bytes)
*** Preparing to test memory region 7fdad1595000 (65536 bytes)
*** Preparing to test memory region 7fdad15c8000 (65536 bytes)
*** Preparing to test memory region 7fdad15fb000 (1048576 bytes)
*** Preparing to test memory region 7fdad17ff000 (1048576 bytes)
*** Preparing to test memory region 7fdad1982000 (65536 bytes)
*** Preparing to test memory region 7fdad19b5000 (1048576 bytes)
*** Preparing to test memory region 7fdad1cb8000 (1048576 bytes)
*** Preparing to test memory region 7fdad1ebc000 (1048576 bytes)
*** Preparing to test memory region 7fdad23c3000 (1048576 bytes)
*** Preparing to test memory region 7fdad24c5000 (1048576 bytes)
*** Preparing to test memory region 7fdad27c8000 (1048576 bytes)
*** Preparing to test memory region 7fdad2c54000 (65536 bytes)
*** Preparing to test memory region 7fdad2c87000 (65536 bytes)
*** Preparing to test memory region 7fdad2cba000 (65536 bytes)
*** Preparing to test memory region 7fdad2dce000 (1048576 bytes)
*** Preparing to test memory region 7fdad2ff3000 (65536 bytes)
*** Preparing to test memory region 7fdad3308000 (1048576 bytes)
*** Preparing to test memory region 7fdad34ac000 (65536 bytes)
*** Preparing to test memory region 7fdad35c0000 (1048576 bytes)
*** Preparing to test memory region 7fdad39f5000 (1048576 bytes)
*** Preparing to test memory region 7fdad3bf9000 (1048576 bytes)
*** Preparing to test memory region 7fdad3eff000 (1048576 bytes)
*** Preparing to test memory region 7fdad4000000 (135168 bytes)
*** Preparing to test memory region 7fdad800c000 (65536 bytes)
*** Preparing to test memory region 7fdad8474000 (1048576 bytes)
*** Preparing to test memory region 7fdad8780000 (65536 bytes)
*** Preparing to test memory region 7fdad8792000 (65536 bytes)
*** Preparing to test memory region 7fdad87c5000 (65536 bytes)
*** Preparing to test memory region 7fdad8bff000 (8392704 bytes)
*** Preparing to test memory region 7fdad9400000 (2097152 bytes)
*** Preparing to test memory region 7fdad960d000 (65536 bytes)
*** Preparing to test memory region 7fdad96c0000 (41963520 bytes)
*** Preparing to test memory region 7fdadc71c000 (4096 bytes)
*** Preparing to test memory region 7fdadc7aa000 (8192 bytes)
*** Preparing to test memory region 7fdadce00000 (14680064 bytes)
*** Preparing to test memory region 7fdaddc10000 (24576 bytes)
*** Preparing to test memory region 7fdaddcad000 (12288 bytes)
*** Preparing to test memory region 7fdaddd00000 (8192 bytes)
*** Preparing to test memory region 7fdaddeeb000 (32768 bytes)
*** Preparing to test memory region 7fdaddf32000 (4096 bytes)
*** Preparing to test memory region 7fdade05c000 (4096 bytes)
*** Preparing to test memory region 7fdade1b2000 (8192 bytes)
*** Preparing to test memory region 7fdade1f5000 (4096 bytes)
.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.O.65182:signal-handler (1775659902) Crashed running signal handler. Providing reduced version of recursive crash report.
65182:M 08 Apr 2026 22:51:42.095 # valkey 255.255.255 crashed by signal: 11, si_code: 1
65182:M 08 Apr 2026 22:51:42.095 # Accessing address: 0x7fdad8bff000
65182:M 08 Apr 2026 22:51:42.095 # Crashed running the instruction at: 0x7fdadde6e58d

------ STACK TRACE ------
EIP:
/usr/lib/libc.so.6(+0x16c58d) [0x7fdadde6e58d]

Backtrace:
/usr/lib/libc.so.6(+0x3e2d0) [0x7fdaddd402d0]
/usr/lib/libc.so.6(+0x16c58d) [0x7fdadde6e58d]
./src/valkey-server 127.0.0.1:6379(+0x143aba) [0x557150c1caba]
./src/valkey-server 127.0.0.1:6379(memtest_test_linux_anonymous_maps+0x210) [0x557150bd9870]
./src/valkey-server 127.0.0.1:6379(printCrashReport+0x109) [0x557150bddc39]
./src/valkey-server 127.0.0.1:6379(+0x10b7a5) [0x557150be47a5]
/usr/lib/libc.so.6(+0x3e2d0) [0x7fdaddd402d0]
./src/valkey-server 127.0.0.1:6379(listUnlinkNode+0xd) [0x557150b794dd]
./src/valkey-server 127.0.0.1:6379(+0x1b1eb6) [0x557150c8aeb6]
./src/valkey-server 127.0.0.1:6379(beforeSleep+0x82) [0x557150cc2ec2]
./src/valkey-server 127.0.0.1:6379(aeMain+0x3f) [0x557150b78e5f]
./src/valkey-server 127.0.0.1:6379(main+0x54f) [0x557150b6823f]
/usr/lib/libc.so.6(+0x276c1) [0x7fdaddd296c1]
/usr/lib/libc.so.6(__libc_start_main+0x89) [0x7fdaddd297f9]
./src/valkey-server 127.0.0.1:6379(_start+0x25) [0x557150b69e25]

------ STACK TRACE DONE ------

------ REGISTERS ------
65182:M 08 Apr 2026 22:51:42.096 #
RAX:00007ffd482bbf20 RBX:0000000000000000
RCX:0000000000000000 RDX:0000000000100000
RDI:00007ffd482bbf20 RSI:00007fdad8bff000
RBP:00007ffd483bbf60 RSP:00007ffd482bbef8
R8 :0000000000000000 R9 :0000000000000000
R10:0000000000000000 R11:00007fdaddc11700
R12:0000000000000001 R13:00007fdad8bff000
R14:0000000000801000 R15:0000000000100000
RIP:00007fdadde6e58d EFL:0000000000010202
CSGSFS:002b000000000033
65182:M 08 Apr 2026 22:51:42.096 * hide-user-data-from-log is on, skip logging stack content to avoid spilling user data.

------ DUMPING CODE AROUND EIP ------
Symbol: (null) (base: (nil))
Module: /usr/lib/libc.so.6 (base 0x7fdaddd02000)
$ xxd -r -p /tmp/dump.hex /tmp/dump.bin
$ objdump --adjust-vma=(nil) -D -b binary -m i386:x86-64 /tmp/dump.bin
------

=== VALKEY BUG REPORT END. Make sure to include from START to END. ===

       Please report the crash by opening an issue on github:

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

  If a module was involved, please open in the module's repo instead.

  Suspect RAM error? Use valkey-server --test-memory to verify it.

  Some other issues could be detected by valkey-server --check-system
[1]    65179 segmentation fault  sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379

现在看起来就是迭代中删除了链表的问题?但是我觉得肯定没这么简单,是的,肯定不会这么简单.

问题总结:

问题根源

  核心问题: 同一个 RDMA 连接可能被多次添加到 pending_list 中,导致:
  1. 第一次迭代处理该连接并可能释放它
  2. 第二次迭代再次访问同一个连接(已释放)→ 段错误

  触发场景:
  - 当 valkey-benchmark 用 Ctrl+C 中断时,多个并发连接几乎同时断开
  - rdmaHandleDisconnect 将连接添加到 pending_list
  - 在处理之前,connRdmaSetWriteHandler 可能因为写事件再次添加同一个连接
  - 或者 connRdmaEventHandler 的回调链导致连接被重复添加

  最终修复方案

  修改1: rdmaHandleDisconnect (src/rdma.c:535-538)

  /* we can't close connection now, let's mark this connection as closed state */
  if (rdma_conn->pending_list_node == NULL) {  // ← 添加检查
      listAddNodeTail(pending_list, conn);
      rdma_conn->pending_list_node = listLast(pending_list);
  }

  修改2: connRdmaSetWriteHandler (src/rdma.c:956-959)

  /* does this connection has pending write data? */
  if (func) {
      if (rdma_conn->pending_list_node == NULL) {  // ← 添加检查
          listAddNodeTail(pending_list, conn);
          rdma_conn->pending_list_node = listLast(pending_list);
      }
  } else if (rdma_conn->pending_list_node) {
      listDelNode(pending_list, rdma_conn->pending_list_node);
      rdma_conn->pending_list_node = NULL;
  }

  修改3: rdmaProcessPendingData - 使用正确的节点 (src/rdma.c:1786)

  listDelNode(pending_list, ln);  // 使用 ln 而不是 rdma_conn->pending_list_node

  修改4: rdmaProcessPendingData - 调整处理顺序 (src/rdma.c:1782-1787)

  // 先调用 handler,再删除节点
  if (callHandler(conn, conn->read_handler)) {
      callHandler(conn, conn->write_handler);
  }

  listDelNode(pending_list, ln);
  rdma_conn->pending_list_node = NULL;

  修复原理

  1. 防止重复添加: 使用 rdma_conn->pending_list_node 作为"已在列表中"的标记
    - NULL = 不在列表中,可以添加
    - 非NULL = 已经在列表中,不要重复添加
  2. 正确的删除: 使用 ln(迭代器返回的节点)而不是 rdma_conn->pending_list_node,因为后者可能被覆盖
  3. 安全的调用顺序: 先调用 handler 通知上层,再从列表中删除,避免访问不完整的连接结构

  调试过程的经验教训

  1. 迭代器失效问题: 最初以为是删除当前节点导致迭代器失效,但 listNext 的设计已经考虑了这种情况
  2. 重复添加问题: 真正的问题是事件驱动架构中的回调可能导致同一对象被多次加入同一个列表
  3. 标记字段的重要性: pending_list_node 字段既记录了节点位置,也可以作为"是否在列表中"的标记

  这是一个典型的事件驱动系统中的重入问题,需要在关键位置添加防护检查。

claude总结代码
#

让我用一个具体的时间线来展示问题:

  时刻 T0: benchmark 需要发送第 N+1 轮 GET 请求
      ↓
  时刻 T1: writeHandler 被调用
      ↓
  时刻 T2: writeHandler → valkeyRdmaWrite → connRdmaHandleCq
      ↓
      connRdmaHandleCq 做了什么?
      1. ibv_get_cq_event()      ← 从 comp_channel 读取通知
      2. ibv_ack_cq_events()     ← 确认通知
      3. ibv_req_notify_cq()     ← 重新武装 CQ(告诉硬件:下次有新事件时再通知我)
      4. ibv_poll_cq() 循环排空  ← 从 CQ 中取出所有完成记录
      ↓
      关键时刻!在 ibv_poll_cq() 循环中,可能拿到了什么?
      - IBV_WC_RDMA_WRITE 完成(我们的发送操作完成了)
      - IBV_WC_RECV_RDMA_WITH_IMM 完成 ← 服务器的响应已经到了!
      ↓
  时刻 T3: connRdmaHandleRecvImm 处理接收事件
      ctx->rx_offset += byte_len;  ← 数据已经在 recv_buf 中了!
      ↓
  时刻 T4: valkeyRdmaWrite 发送完请求,返回到 writeHandler
      ↓
  时刻 T5: writeHandler 注册 AE_READABLE 事件
      aeCreateFileEvent(el, c->context->fd, AE_READABLE, readHandler, c);
      ↓
  时刻 T6: writeHandler 返回,回到事件循环
      ↓
  时刻 T7: epoll_wait() 等待 comp_channel->fd 变成可读...

      ❌ 问题:comp_channel->fd 永远不会变成可读!

      为什么?
      1. CQ 在 T2 时刻已经被排空了
      2. ibv_req_notify_cq 在 T3 时刻(排空之前)就已经重新武装了
      3. 武装之后到达的新事件会触发通知,但武装之前已经在 CQ 里的事件不会!
      4. 服务器的响应在 T2 时刻就已经被取出来了,不是"新"事件

      结果:
      - recv_buf 中有数据(rx_offset > recv_offset)
      - CQ 是空的
      - comp_channel->fd 不会变成可读
      - readHandler 永远不会被调用
      - 死锁!💀

重要推理:

就是` ./src/valkey-benchmark --rdma -h 10.0.0.1 -t get -n 100000 -c 1`
这个命令最终一定会执行完毕(不会卡住),但是中间的现象是会出现卡顿,就是RPS突然有一段时间为0,然后又突然非常高,处理了一段时间,然后又会卡住,之后又会处理一段时间.
目前在这个分支上做了两次改动都没有解决这个问题,能否深入分析一下呢?

● 好的,让我深入分析这个 RDMA benchmark 卡顿问题。首先让我了解当前分支的改动和相关代码。

● Bash(git log --oneline -20)
  ⎿  e041a64b9 Signed-off-by: Ada-Church-Closure <nunotabashinobu066@gmail.com>
     e8ca6c07b Fix VLA warning in linenoise and enable -Werror (#3439)
     1db8bab50 Big endian bitmap byte order mismatch fix (#3401)
     … +17 lines (ctrl+o to expand)

● Bash(git diff unstable -- deps/libvalkey/src/rdma.c)
  ⎿  diff --git a/deps/libvalkey/src/rdma.c b/deps/libvalkey/src/rdma.c
     index b0deba725..3db9a7605 100644
     --- a/deps/libvalkey/src/rdma.c
     … +314 lines (ctrl+o to expand)

● Bash(git log unstable..HEAD --oneline)
  ⎿  e041a64b9 Signed-off-by: Ada-Church-Closure <nunotabashinobu066@gmail.com>
     e8ca6c07b Fix VLA warning in linenoise and enable -Werror (#3439)
     1db8bab50 Big endian bitmap byte order mismatch fix (#3401)
     … +180 lines (ctrl+o to expand)

● 好的,我看到了当前分支的改动。现在让我深入分析 RDMA 代码和 benchmark 的事件循环。

● Read(deps/libvalkey/src/rdma.c)
  ⎿  Read 1193 lines

● Explore(分析 benchmark 事件循环)
  ⎿  Done (14 tool uses · 114.1k tokens · 1m 4s)

● 非常好,现在让我看看 benchmark 中已有的 speculative read 修复具体是怎样的。

● Read(src/valkey-benchmark.c)
  ⎿  Read 120 lines

● Read(src/valkey-benchmark.c)
  ⎿  Read 130 lines

● 现在让我仔细检查 connRdmaHandleCq 中的关键竞态窗口,以及 valkeyBufferRead 的 zero-copy 路径。

● Explore(分析 valkeyBufferRead ZC 路径)
  ⎿  Done (2 tool uses · 31.8k tokens · 34.7s)

● 现在我掌握了所有关键代码。让我来确认 benchmark 创建连接时是否去掉了 VALKEY_BLOCK 标志。

● Search(pattern: "valkeyConnectWrapper", path: "src")
  ⎿  Found 4 files (ctrl+o to expand)

● Search(pattern: "valkeyConnectWrapper", path: "src/cli_common.c", output_mode: "content")
  ⎿  Found 31 lines (ctrl+o to expand)

● 好的。benchmark 中调用 valkeyConnectWrapper(..., 1, ...) 传了 nonblock=1,所以 VALKEY_BLOCK 标志会被清除。现在让我追踪 VALKEY_OPT_NONBLOCK 如何影响 flags:

● Search(pattern: "VALKEY_OPT_NONBLOCK", path: "deps/libvalkey/src/valkey.c", output_mode: "content")
  ⎿  Found 34 lines (ctrl+o to expand)

● 好,确认了:benchmark 使用 nonblock=1 创建连接,所以 VALKEY_BLOCK 不会被设置。这意味着在 valkeyRdmaReadZC 中,非阻塞分支会在没有数据时返回 0。

  现在让我仔细分析 connRdmaHandleCq 中的竞态条件——这是问题的核心:

● Read(deps/libvalkey/src/rdma.c)
  ⎿  Read 80 lines

● 现在我已经完全理解了整个流程。让我梳理一下根因分析。

● 根因分析

  经过深入分析,你现有的两层修复(speculative read + EAGAIN fallthrough poll)只覆盖了一个场景,但仍有一个 未覆盖的 lost wakeup 窗口 导致间歇卡顿。以下是完整分析:

  背景:RDMA completion 通知机制

  RDMA 的 ibv_req_notify_cq + ibv_get_cq_event 是边沿触发的:
  1. 调用 ibv_req_notify_cq(cq, 0) 来 arm CQ
  2. 在 arm 之后新到达的 CQE 才会生成一个 completion event(使 comp_channel->fd 变为 readable)
  3. 调用 ibv_get_cq_event 消费这个 event
  4. 调用 ibv_poll_cq 取走所有 CQE

  你已修复的场景(writeHandler 中的 speculative read)

  writeHandler
    └─ valkeyRdmaWrite()
         └─ connRdmaHandleCq()  ← 这里 drain 了 CQ,包括服务端回复的 RECV_IMM
              // rx_offset 已更新,数据在 recv_buf 里
    // write 完成,注册 AE_READABLE
    // speculative readHandler()  ← 你的修复:立即尝试读取已缓存的数据

  这确实修复了 "write 路径消费了 read completion" 的场景。

  仍然存在的 lost wakeup 场景

  问题出在 connRdmaHandleCq() (rdma.c:535-614) 的核心竞态:

  connRdmaHandleCq():
    (1) ibv_get_cq_event()        → 消费一个 event(或 EAGAIN)
    (2) ibv_ack_cq_events()       → ack 那个 event
    (3) ibv_req_notify_cq()       → 重新 arm CQ
    ─── 竞态窗口开始 ───
    (4) ibv_poll_cq() loop        → drain 所有 CQE
    ─── 竞态窗口结束 ───

  关键问题:如果一个新的 CQE 恰好在 (3) arm 之后、(4) poll 期间到达,它会被 ibv_poll_cq 取走并处理。但此时 CQ notification 已经被消费了(arm 之后的第一个 CQE
  会触发一个新 event)。然而问题不在这里。

  真正的问题场景是 readHandler 中 valkeyBufferRead 只调用一次 read:

  readHandler:
    valkeyBufferRead()
      └─ valkeyRdmaReadZC()
           ├─ (早期检查) recv_offset < rx_offset? 返回缓存数据
           ├─ connRdmaHandleCq()   ← 处理 CQ,更新 rx_offset
           ├─ recv_offset < rx_offset? 返回数据
           └─ 非阻塞模式? return 0
    // 只读到了一批数据,feed 给 reader
    valkeyGetReply() loop
      // 处理所有能解析出的 reply
      // 如果 pending == 0:
      clientDone() → resetClient() → writeHandler()

  现在考虑这个时序 (-c 1,单连接,pipeline=1):

  T1: writeHandler 发送 GET 请求
  T2: aeMain 的 epoll_wait 等待 comp_channel->fd 可读
  T3: 服务端回复到达,CQE 生成,comp_channel->fd 变为 readable
  T4: readHandler 被调用
       └─ valkeyRdmaReadZC
            └─ connRdmaHandleCq():
                 ibv_get_cq_event()   ← 消费 event
                 ibv_ack_cq_events()
                 ibv_req_notify_cq()  ← re-arm
                 ibv_poll_cq()        ← 取走 RECV_IMM,rx_offset 更新
       └─ 返回数据
       reply 解析完毕,pending==0
       clientDone() → resetClient() → writeHandler()
            └─ 发送新的 GET 请求 (valkeyRdmaWrite)
                 └─ connRdmaHandleCq():   ← ★ 这里又 drain CQ 一次
                      ibv_get_cq_event()   ← EAGAIN (还没有新 event)
                      // EAGAIN 分支:ev_cq = ctx->cq
                      ibv_req_notify_cq()  ← re-arm(关键!)
                      ibv_poll_cq()        ← 可能有 SEND completion

  T5: write 完成,speculative readHandler
       └─ valkeyRdmaReadZC
            └─ 早期检查: recv_offset == rx_offset (没有缓存数据)
            └─ connRdmaHandleCq():
                 ibv_get_cq_event()   ← EAGAIN
                 ibv_req_notify_cq()  ← re-arm 又一次!
                 ibv_poll_cq()        ← ret == 0 (服务端还没回复)
            └─ 非阻塞模式: return 0
       // readHandler: valkeyBufferRead 返回 OK (nread==0)
       // 无数据可解析,退出

  T6: 回到 aeMain 的 epoll_wait,等待 comp_channel->fd 可读

  到这一步一切正常。但问题在于:如果服务端的回复在 T5 的 ibv_req_notify_cq() 和 ibv_poll_cq() 之间到达:

  T5-细化:
    ibv_req_notify_cq()     ← arm CQ(此后的第一个新 CQE 会产生 event)
    ─── 这时 server 的回复 CQE 到达 ──────
    ─── comp_channel->fd 变为 readable ───
    ibv_poll_cq()           ← 取走了这个 CQE!rx_offset 更新!
    ibv_poll_cq()           ← ret == 0,退出循环

    // 但 check: recv_offset < rx_offset?  是!有数据!
    // 等等... 看代码!

  等一下,让我再仔细看 valkeyRdmaReadZC 的代码流(rdma.c:664-710):

  pollcq:
      if (connRdmaHandleCq(c) == VALKEY_ERR)  return VALKEY_ERR;

      if (ctx->recv_offset < ctx->rx_offset) {  // ← 会检查!
          // 返回数据
      }

      if (!(c->flags & VALKEY_BLOCK)) return 0;  // 非阻塞返回 0

  这里确实在 connRdmaHandleCq 后检查了 recv_offset < rx_offset。如果 poll 期间有 RECV_IMM 被处理,数据确实会被返回。

  但这只覆盖了 speculative read 那一次调用。如果 speculative read 期间没有数据(服务端还没回复),然后服务端回复了:

  T5: speculative read: connRdmaHandleCq() 在 ibv_req_notify_cq() 后 poll 到 0 个 CQE
      → return 0, readHandler 退出

  T6: 服务端回复的 CQE 到达
      → CQ 已经被 arm 了(T5 中的 ibv_req_notify_cq)
      → comp_channel->fd 变为 readable
      → epoll_wait 返回
      → readHandler 被调用 → 正常处理

  这个场景没问题。那问题到底在哪?

  让我重新审视 valkeyRdmaWrite (rdma.c:755-797):

  static ssize_t valkeyRdmaWrite(valkeyContext *c) {
      ...
  pollcq:
      if (connRdmaHandleCq(c) == VALKEY_ERR) return VALKEY_ERR;

      if (ctx->tx_offset == ctx->tx_length) goto waitcq;  // 需要等待新 TX buffer

      towrite = ...;
      ret = connRdmaSend(ctx, cm_id, c->obuf + wrote, towrite);
      wrote += ret;
      if (wrote == data_len) return data_len;  // ← 写完了,直接返回

  waitcq:
      if (valkeyRdmaPollCqCm(c, end) == VALKEY_OK) goto pollcq;

  关键发现:valkeyRdmaWrite 在 connRdmaHandleCq() 后没有检查 recv_offset < rx_offset! 它只关心写入是否完成。但 connRdmaHandleCq() 是一个 drain-all 循环(goto
  pollcq),它会处理所有类型的 CQE,包括 IBV_WC_RECV_RDMA_WITH_IMM。

  这意味着 valkeyRdmaWrite 的 connRdmaHandleCq 可能消费了回复数据的 CQE(更新了 rx_offset),同时也消费了 comp_channel 上的 event(或者由于 re-arm 导致后续不会有新
  event)。

  现在,你的 speculative read 修复确实会在 write 完成后立即调用 readHandler。但它 只调用一次。

  问题的核心时序(-c 1, pipeline=1,正常快速流):

  循环 N:
    writeHandler → valkeyRdmaWrite:
      connRdmaHandleCq():
        ibv_get_cq_event() → 得到 event(来自上一轮 server 发的 RegisterXferMemory cmd 的完成)
        ibv_ack_cq_events()
        ibv_req_notify_cq()  ← re-arm
        ibv_poll_cq() → 处理了 RECV(RegisterXferMemory) + 可能还有上一轮的 SEND 完成
      connRdmaSend() → 发出请求
      return data_len

    speculative readHandler:
      valkeyRdmaReadZC:
        早期检查: recv_offset < rx_offset? 可能不成立(上面处理的是 RegisterXferMemory cmd,不是数据)
        connRdmaHandleCq():
          ibv_get_cq_event() → EAGAIN
          ibv_req_notify_cq()  ← re-arm 又一次
          ibv_poll_cq() → 0 个 CQE(server 还没回复)
        非阻塞: return 0
      readHandler 退出,没有读到数据

    回到 epoll_wait...
    server 回复到达 → CQE → comp_channel readable → readHandler → 正常

  这个也正常。但考虑 高速场景 下 server 回复非常快:

  循环 N:
    writeHandler → valkeyRdmaWrite:
      connRdmaHandleCq():
        ibv_get_cq_event() → EAGAIN(上一轮 speculative read 已经 drain 了)
        ibv_req_notify_cq()  ← re-arm
        ibv_poll_cq() → 0
      connRdmaSend() → 发出请求
      return

    speculative readHandler:
      valkeyRdmaReadZC:
        早期检查: 没有缓存数据
        connRdmaHandleCq():
          ibv_get_cq_event() → EAGAIN
          ibv_req_notify_cq()  ← re-arm
          ─── server 回复到达,CQE 入队 ───
          ─── comp_channel 产生 event (fd readable) ───
          ibv_poll_cq() → RECV_IMM! rx_offset 更新!
          ibv_poll_cq() → 0
          return OK
        recv_offset < rx_offset → 返回数据! ✓
      valkeyGetReply → 解析到 reply, pending == 0
      clientDone → resetClient → writeHandler(递归)
      ─── 但 rdma_in_speculative_read == 1 !!!! ───

  等等,resetClient 中:

  static void resetClient(client c) {
      aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE);
      aeDeleteFileEvent(el, c->context->fd, AE_READABLE);
      if (config.ct == VALKEY_CONN_RDMA) {
          writeHandler(el, c->context->fd, c, 0); /* 直接调用 writeHandler */
      } else {
          aeCreateFileEvent(el, c->context->fd, AE_WRITABLE, writeHandler, c);
      }
      ...
  }

  所以 resetClient → writeHandler 会被直接调用。在这个 writeHandler 中:

  writeHandler:
    ...写完之后...
    aeCreateFileEvent(el, c->context->fd, AE_READABLE, readHandler, c);
    \#ifdef USE_RDMA
    if (config.ct == VALKEY_CONN_RDMA && !rdma_in_speculative_read) {
        // rdma_in_speculative_read == 1 (我们在递归中!)
        // 所以这个 speculative read 被跳过了!!!!
    }
    \#endif
    return;

  这就是 bug! 在递归场景下:

  1. speculative readHandler 中读到了数据
  2. clientDone → resetClient → writeHandler 被递归调用
  3. 新的 writeHandler 发出新请求
  4. 但因为 rdma_in_speculative_read == 1,新的 speculative read 被跳过
  5. 之前 connRdmaHandleCq 消费了 comp_channel event(步骤 T5 的 ibv_get_cq_event 得到了 event,或者虽然 EAGAIN 但 re-arm + poll 消费了 CQE)
  6. 如果这个 event 是唯一的 event,而新请求的回复在 ibv_req_notify_cq 和 ibv_poll_cq 之间或之前到达,comp_channel->fd 不会再变为 readable
  7. epoll_wait 阻塞在 comp_channel->fd 上等待新 event
  8. 但 CQE 已经被提前处理过了或者新 event 不会产生(因为 arm 时 CQ 已经是空的,后续 CQE 会产生新 event)

  不对,让我重新想清楚。实际上 ibv_req_notify_cq 之后的第一个新 CQE 一定会产生 event。所以如果 poll 把 CQE 取走了但没有处理到它,或者 CQE 在 arm 之后到达但也在 poll
   中被取走了,event 已经产生了一个但 ibv_get_cq_event 那里得到的是 EAGAIN(因为之前 speculative read 中的 connRdmaHandleCq 第一行 ibv_get_cq_event 得到了
  EAGAIN,然后 re-arm,然后 poll 取走了一个 CQE)——这时 event 产生了但在下一次 ibv_get_cq_event 之前没有被消费,所以 comp_channel->fd 还是 readable 的。

  让我更精确地分析。comp_channel->fd 的可读性取决于是否有未消费的 event。关键在于:

  - ibv_get_cq_event 消费一个 event → fd 可能变为不可读
  - ibv_req_notify_cq arm 后,新 CQE 产生一个新 event → fd 变为可读

  所以真正的问题时序是:

  speculative readHandler:
    connRdmaHandleCq():
      ibv_get_cq_event() → EAGAIN (fd 上没有未消费的 event)
      // ev_cq = ctx->cq (EAGAIN 分支)
      ibv_req_notify_cq() ← arm
      ibv_poll_cq() → 0 (没有 CQE)
      return OK
    recv_offset == rx_offset → 没有数据
    非阻塞: return 0
  readHandler 退出

  ── 返回 writeHandler,rdma_in_speculative_read = 0 ──
  返回 aeMain → epoll_wait

  此时 comp_channel->fd 不可读(没有未消费的 event)
  CQ 已经 arm 了

  ── server 回复到达 ──
  CQE 入队 → 产生 event → comp_channel->fd 可读
  epoll_wait 返回 → readHandler → 正常处理 ✓

  这个场景也是正常的... 让我再想想什么时候会真正卡住。

  我来看一个更微妙的场景——RX buffer 满了需要重新注册的情况:

  valkeyRdmaReadZCDone (rdma.c:712-720):
  static ssize_t valkeyRdmaReadZCDone(valkeyContext *c) {
      RdmaContext *ctx = c->privctx;
      if (ctx->recv_offset == ctx->recv_length) {
          return connRdmaRegisterRx(c, cm_id);  // 发送 RegisterXferMemory 命令
      }
      return VALKEY_OK;
  }

  connRdmaRegisterRx 发送一个 RegisterXferMemory 命令(通过 rdmaSendCommand → ibv_post_send),并且重置 rx_offset = 0, recv_offset = 0。

  当 valkeyBufferRead 调用 read_zc_done 后,如果 RX buffer 满了,它会发送注册命令。这个 SEND 操作会产生一个 SEND completion CQE。

  现在,关键问题来了:valkeyBufferRead 只调用 read_zc 一次(不是循环调用)。这意味着:

  readHandler:
    valkeyBufferRead:
      read_zc() → 返回部分数据(假设刚好填满 RX buffer 到 recv_length)
      valkeyReaderFeed()
      read_zc_done() → connRdmaRegisterRx() 发送新的 RX buffer 注册
        // rx_offset = 0, recv_offset = 0
    valkeyGetReply() loop → 解析 replies
      // 如果所有数据已经足以解析完所有 pending reply:
      clientDone → resetClient → writeHandler

  但如果 server 在收到新的 RegisterXferMemory 后立即开始发送更多数据到新的 RX buffer?那些数据的 CQE 不会被处理,直到下一次 readHandler 被调用。

  不过这在正常流程中应该不会是问题,因为 benchmark 是请求-响应模式的。

  让我换一个角度来分析。让我专注于你说的现象:-c 1 (单连接),RPS 间歇为 0。

  让我看 cliWriteConn:

● Search(pattern: "cliWriteConn", path: "src/cli_common.c", output_mode: "content")
  ⎿  Found 21 lines (ctrl+o to expand)

● Read(src/cli_common.c)
  ⎿  Read 30 lines

● 现在让我看 valkeyBufferWrite:

● Search(pattern: "^int valkeyBufferWrite", path: "deps/libvalkey/src/valkey.c", output_mode: "content")
  ⎿  Found 26 lines (ctrl+o to expand)

● 好的,现在让我重新思考这个问题。valkeyRdmaWrite 是阻塞写(使用 valkeyRdmaPollCqCm 循环等待),即使 context 没有 VALKEY_BLOCK 标志。让我确认这一点:

  valkeyRdmaWrite (rdma.c:755-797) 使用 valkeyRdmaPollCqCm(c, end) 来等待——它里面有 poll() 调用。end 来自 valkeyCommandTimeoutMsec。让我检查 no-timeout
  场景下会怎样:

● Search(pattern: "valkeyCommandTimeoutMsec", path: "deps/libvalkey/src/valkey.c", output_mode: "content")
  ⎿  Found 0 lines

● Search(pattern: "valkeyCommandTimeoutMsec", path: "deps/libvalkey/src", output_mode: "content")
  ⎿  Found 65 lines (ctrl+o to expand)

● Search(pattern: "valkeyContextTimeoutMsec", path: "deps/libvalkey/src/valkey_private.h", output_mode: "content")
  ⎿  Found 45 lines (ctrl+o to expand)

● 好,所以当 command_timeout == NULL 时,timed = INT_MAX(即 msec = INT_MAX)。这意味着 valkeyRdmaWrite 的 end = vk_msec_now() + INT_MAX,valkeyRdmaPollCqCm 中的
  poll() 超时会是 end - now ≈ INT_MAX 毫秒——即非常长时间的阻塞 poll()。

  这就是关键! valkeyRdmaWrite 在等待 TX buffer 可用时,调用 poll() 阻塞了,这期间 benchmark 的事件循环完全不运转。但对于正常的 GET 请求(很小),TX buffer
  应该不会满。

  让我换个思路,关注 connRdmaHandleCq 被多次调用时的 arm/event 交互:

  状态: CQ 已 arm, comp_channel event 队列为空

  1. write path: connRdmaHandleCq()
     ibv_get_cq_event() → EAGAIN (无 event)
     ibv_req_notify_cq()  ← 再次 arm (幂等的, CQ 本来就是 arm 的)
     ibv_poll_cq() → 0

  2. write 成功返回

  3. speculative read: connRdmaHandleCq()
     ibv_get_cq_event() → EAGAIN
     ibv_req_notify_cq()  ← arm
     ibv_poll_cq() → 0
     return 0 (非阻塞)

  4. epoll_wait 等待...
     server 回复 → CQE → event → fd readable → readHandler

  这个没问题。但如果在步骤 3 的 ibv_req_notify_cq 和 ibv_poll_cq 之间有 CQE 到达:

  3'. speculative read: connRdmaHandleCq()
     ibv_get_cq_event() → EAGAIN
     ibv_req_notify_cq()  ← arm
     --- CQE 到达 (server 回复) ---
     --- event 产生, comp_channel->fd 变为 readable ---
     ibv_poll_cq() → 得到 RECV_IMM, rx_offset 更新
     ibv_poll_cq() → 0, 返回

     recv_offset < rx_offset → 返回数据 ✓
     readHandler 处理 reply → clientDone → resetClient → writeHandler(递归调用)

  4'. 新的 writeHandler:
     valkeyRdmaWrite → connRdmaHandleCq():
       ibv_get_cq_event() → 得到之前产生的 event! ← 消费了它
       ibv_ack_cq_events()
       ibv_req_notify_cq()  ← re-arm
       ibv_poll_cq() → 可能得到 SEND completion 等
     connRdmaSend() → 发出新请求
     return

     speculative read 被跳过! (rdma_in_speculative_read == 1)
     返回到步骤 3 的 readHandler → readHandler 退出
     rdma_in_speculative_read = 0
     回到 aeMain → epoll_wait

  5'. 此时 comp_channel->fd 是什么状态?
     步骤 4' 的 ibv_get_cq_event 消费了唯一的 event
     步骤 4' 的 ibv_req_notify_cq 重新 arm
     步骤 4' 的 ibv_poll_cq 清空了 CQ

     → comp_channel->fd 不可读! CQ 已 arm!
     → 需要等 server 回复新请求,新 CQE 产生新 event
     → 这是正常的,新请求的回复会产生新 event
     → epoll_wait 应该能正常唤醒 ✓

  Hmm,这个场景实际上也是正常的。让我再深入想...

  关键洞察:也许问题不在于"永远卡住",而在于 connRdmaHandleCq 中多次重复 arm 的代价。每次 ibv_req_notify_cq 只是重新 arm,但 ibv_get_cq_event 的 EAGAIN 分支和 event
   分支都会调用它。真正的问题可能是 valkeyRdmaWrite 里的 阻塞 poll()。

  让我重新检查 valkeyRdmaWrite 的行为。benchmark 没有设置 command_timeout(c->command_timeout == NULL),所以 timed = INT_MAX。当需要等待新 TX buffer(tx_offset ==
  tx_length)时:

  valkeyRdmaWrite:
  pollcq:
    connRdmaHandleCq() → drain CQ
    tx_offset == tx_length? → goto waitcq
  waitcq:
    valkeyRdmaPollCqCm(c, end) → poll(pfd, 2, INT_MAX - now)  ← 阻塞!

  但对于 GET key:000000000000 这样的小请求,TX buffer 不应该满(1MB 大小)。每次请求只发几十字节,而 server 回复 RegisterXferMemory 会重置 tx_offset。

  等等——让我仔细看 TX buffer 的重置逻辑。tx_offset 在 connRdmaHandleRecv 的 RegisterXferMemory 分支中被重置:

  case RegisterXferMemory:
      ctx->tx_addr = ...;
      ctx->tx_length = ntohl(cmd->memory.length);
      ctx->tx_key = ntohl(cmd->memory.key);
      ctx->tx_offset = 0;  // ← 重置

  这是 server 端发来的。当 server 消费完 RX buffer 后,它会重新注册并发送一个新的 RegisterXferMemory 给 client。只有在 server 端 准备好新 buffer 之后 client 的
  tx_offset 才会被重置。

  如果 client 发送大量数据填满了 TX buffer,但 server 还没来得及处理并发送新的 RegisterXferMemory,那 client 的 write 就会阻塞在 poll() 里等待。但这对于 -t get
  不太可能发生。

  让我现在集中精力看另一个角度——CQ event 的"消费但未产生新通知"的极端竞态。

  RDMA CQ 通知的精确语义是:
  - ibv_req_notify_cq(cq, 0) 请求 solicited + unsolicited 通知
  - 调用后,下一个 添加到 CQ 的 CQE 会产生一个 completion event
  - 如果在 arm 时 CQ 中已经有 CQE,它们不会触发通知——只有新加入的才会

  所以标准做法是:
  1. ibv_req_notify_cq()     // arm
  2. ibv_poll_cq() loop      // drain all existing CQE
  // 如果 poll 了任何 CQE → 处理它们,然后回到 1
  // 如果 poll 了 0 → 安全,任何新 CQE 都会产生通知

  当前代码确实是这么做的。但在 EAGAIN 分支中有一个微妙差异。让我重新仔细看:

  if (ibv_get_cq_event(ctx->comp_channel, &ev_cq, &ev_ctx) < 0) {
      if (errno != EAGAIN) { return VALKEY_ERR; }
      // EAGAIN: 没有 pending event
      ev_cq = ctx->cq;
  } else {
      ibv_ack_cq_events(ctx->cq, 1);  // ack 消费了的 event
  }

  ibv_req_notify_cq(ev_cq, 0);  // re-arm

  pollcq:
  ibv_poll_cq() loop ...

  当 EAGAIN 时(没有 event),CQ 可能已经 arm 也可能没有。但这里又调用 ibv_req_notify_cq。多次 arm 是幂等的吗?根据 RDMA verbs 规范,对已经 arm 的 CQ 再次调用
  ibv_req_notify_cq 是安全的——它只是确保 CQ 被 arm。

  但关键问题是:如果之前的 arm 已经因为一个 CQE 产生了 event(event 在 comp_channel 的内核队列中但我们还没 ibv_get_cq_event),而我们这里的 ibv_get_cq_event 得到了
  EAGAIN——这可能吗?

  不,ibv_get_cq_event 在非阻塞模式下如果有 pending event 就返回它,否则返回 EAGAIN。所以如果有 event,它一定会返回 event 而不是 EAGAIN。

  好的,让我从完全不同的角度来想这个问题。看看是否有 event loop 注册/注销 的问题。

  回到 benchmark 的 resetClient(valkey-benchmark.c:557-568):

  static void resetClient(client c) {
      aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE);
      aeDeleteFileEvent(el, c->context->fd, AE_READABLE);  // ← 删除了 READABLE!
      if (config.ct == VALKEY_CONN_RDMA) {
          writeHandler(el, c->context->fd, c, 0);
      } else {
          aeCreateFileEvent(el, c->context->fd, AE_WRITABLE, writeHandler, c);
      }
      ...
  }

  对于 RDMA,resetClient 先删除 AE_READABLE,然后直接调用 writeHandler。writeHandler 完成写入后会 aeCreateFileEvent(..., AE_READABLE, readHandler, c) 再注册回来。

  但如果 writeHandler 在中途 return 了呢? 比如 isBenchmarkFinished 返回 true,或者部分写入(nwritten > 0 但不是全部)。让我检查 RDMA 的 cliWriteConn 返回路径:

  valkeyRdmaWrite 要么写完所有数据(返回 data_len),要么返回 VALKEY_ERR。它不会返回部分写入。cliWriteConn 在 done=1 时返回 buf_len,在 VALKEY_ERR 时返回 -1。

  在 writeHandler 中 (line 886):
  const ssize_t nwritten = cliWriteConn(c->context, ptr, writeLen);
  if (nwritten != writeLen) {
      if (nwritten == -1 && errno != EAGAIN) {
          // error, freeClient
      } else if (nwritten > 0) {
          c->written += nwritten;
          return;  // ← 部分写入,return 而不注册 READABLE
      }
  }

  对于 RDMA,cliWriteConn 返回 buf_len(成功)或 -1(失败),不会返回部分写入。所以 nwritten == writeLen 成立,进入 else 分支注册 READABLE。

  不对,等等。 看 cliWriteConn(cli_common.c:147-176)更仔细:

  c->obuf = sdscatlen(c->obuf, buf, buf_len);
  if (valkeyBufferWrite(c, &done) == VALKEY_ERR) {
      if (!(c->flags & VALKEY_BLOCK)) errno = EAGAIN;
      // rollback buffer
      return -1;
  }
  if (done) {
      sdsclear(c->obuf);
      return buf_len;
  }
  // not done:
  if (sdslen(c->obuf) > buf_len) {
      sdsrange(c->obuf, 0, -(buf_len + 1));
      return 0;
  }
  size_t left = sdslen(c->obuf);
  sdsclear(c->obuf);
  return buf_len - left;

  valkeyBufferWrite 调用 c->funcs->write(c) 即 valkeyRdmaWrite。这个函数使用整个 c->obuf 来写,不是 buf_len。所以如果之前有残留数据在 c->obuf 中,它会一起写。

  但问题是,valkeyRdmaWrite 通过 sdslen(c->obuf) 获取长度。如果写成功,它返回 data_len = sdslen(c->obuf)。然后 valkeyBufferWrite 检查 nwritten ==
  sdslen(c->obuf),如果是的话清空 obuf,设置 done = 1。

  所以对于 RDMA,done 应该总是 1(成功时),cliWriteConn 返回 buf_len。

  好,让我从现象倒推。你说 RPS "间歇性为 0"——突然停顿一段时间然后又恢复。这种模式暗示不是永久死锁,而是某种 超时恢复 机制。

  什么东西有超时?showThroughput 每 250ms 被调用一次(作为 ae 的 time event)。如果 benchmark 卡住了,time event 也不会执行(ae 在 epoll_wait 中阻塞了)。

  突然的想法:如果 epoll_wait 的超时正好是 ae 中 time event 的到期时间,那么即使没有 fd event,time event 到期时 aeProcessEvents 也会返回。让我检查 ae 的
  aeProcessEvents:

● Search(pattern: "int aeProcessEvents", path: "src/ae.c", output_mode: "content")
  ⎿  Found 31 lines (ctrl+o to expand)

● Read(src/ae.c)
  ⎿  Read 60 lines

● 好,ae 的 time event (showThroughput) 每 250ms 执行一次。所以 aeApiPoll(即 epoll_wait)的超时最多是 250ms。即使 comp_channel->fd 没有 event,epoll_wait 也会在
  250ms 后返回处理 time event,然后重新 epoll_wait。

  这与你描述的现象完全吻合:RPS 突然为 0 一段时间(~250ms),然后又突然很高。

  因为:
  1. 卡在 epoll_wait(没有 fd event,但 comp_channel->fd 上实际上还是有未消费的 event,或者没有——需要确认)
  2. 250ms 后 epoll_wait 超时返回(为了处理 showThroughput time event)
  3. 再次进入 epoll_wait 此时如果 fd 仍然 readable,它会立刻返回

  等等,这还是不对。epoll 的机制是:如果 fd 是 level-triggered(默认),只要 fd 上有未读的 event,epoll_wait 就会立即返回。所以如果 comp_channel->fd 是 readable
  的,epoll 不会阻塞 250ms。

  问题是 epoll_wait 阻塞了,说明 comp_channel->fd 上确实没有未消费的 event。那 CQE 呢?CQE 可能在之前的 ibv_poll_cq 中被取走了,event 也被 ibv_get_cq_event
  消费了(或者 re-arm 后没有新 CQE 产生新 event)。

  我现在找到了真正的 bug! 让我重新审视 connRdmaHandleCq 的 EAGAIN 分支:

  if (ibv_get_cq_event(ctx->comp_channel, &ev_cq, &ev_ctx) < 0) {
      if (errno != EAGAIN) return VALKEY_ERR;
      // EAGAIN 分支
      ev_cq = ctx->cq;
  } else {
      ibv_ack_cq_events(ctx->cq, 1);
  }

  ibv_req_notify_cq(ev_cq, 0);   // re-arm

  pollcq:
  ibv_poll_cq loop ...

  在 EAGAIN 分支中:
  - 没有 event 可消费
  - 但可能 CQ 已经不是 arm 状态(上一次 CQE 到达时消耗了 arm)
  - 调用 ibv_req_notify_cq re-arm
  - 然后 poll

  如果在 re-arm 之前,CQ 中已经有 CQE 但没有 event 通知(因为上一次的 arm 已经被消耗了),那么 ibv_poll_cq 会取到这些 CQE,但不会有新的 event 产生(因为 event 只在
  CQE 加入 CQ 时产生,不是在 arm 时产生)。

  不对,ibv_req_notify_cq 的语义是:在 arm 之后,下一个加入 CQ 的 CQE 会触发 event。已经在 CQ 中的 CQE 不会触发。所以:

  状态: CQ 中有 1 个 CQE(之前的 arm 已被消耗)

  connRdmaHandleCq():
    ibv_get_cq_event() → EAGAIN (event 队列为空)
    ibv_req_notify_cq() → arm
    ibv_poll_cq() → 取到 1 个 CQE,处理它
    ibv_poll_cq() → 0
    return OK

  // 现在 CQ 为空,CQ 已 arm
  // 新 CQE 会产生 event ← 正常

  这个也没问题。但如果在 ibv_poll_cq 之后、函数返回之前,一个新 CQE 到达:

    ibv_req_notify_cq() → arm
    ibv_poll_cq() → 取到旧 CQE
    ibv_poll_cq() → 0
    --- 新 CQE 到达 → event 产生 → comp_channel->fd readable ---
    return OK

  event 在 comp_channel->fd 上等待。当 readHandler 回到 epoll_wait 时,fd 是 readable 的,所以会被立即唤醒。

  但如果这个 connRdmaHandleCq 是在 speculative read 中被调用的呢?speculative read 中的 valkeyRdmaReadZC 会检查 recv_offset < rx_offset,如果有数据就返回。如果新
  CQE 是 RECV_IMM,ibv_poll_cq 取到它后 rx_offset 会更新。等等,但新 CQE 是在 ibv_poll_cq() → 0 之后到达的,所以 ibv_poll_cq 没有取到它。

    ibv_poll_cq() → 0
    --- 新 CQE 到达 ---
    return OK

  valkeyRdmaReadZC:
    recv_offset < rx_offset? → NO (新 CQE 没被处理)
    非阻塞: return 0

  readHandler: nread == 0, 退出
  epoll_wait: comp_channel->fd readable (有 event) → 立即返回 → readHandler ✓

  这个也正常。

  好吧,让我换个思路。也许问题出在 ibv_poll_cq 循环取走了所有 CQE 包括一个 RECV_IMM,但对应的 event 已经被之前的某次调用消费了,而且 re-arm 之后没有新 CQE 产生新
  event。

  具体来说:

  T1: server 发回复 → CQE_A 加入 CQ → (CQ arm) → event_A 产生
  T2: client writeHandler → valkeyRdmaWrite → connRdmaHandleCq:
      ibv_get_cq_event() → event_A ← 消费
      ibv_ack_cq_events()
      ibv_req_notify_cq() → re-arm
      ibv_poll_cq() → CQE_A (RECV_IMM) → rx_offset 更新
      ibv_poll_cq() → 0
  T3: write 完成
  T4: speculative readHandler:
      valkeyRdmaReadZC:
        早期检查: recv_offset < rx_offset → YES! (T2 更新了 rx_offset)
        返回数据,不调用 connRdmaHandleCq
      valkeyReaderFeed()
      read_zc_done() → 可能 connRdmaRegisterRx
      valkeyGetReply → 解析 reply → clientDone → resetClient → writeHandler(递归)

  T5: 递归 writeHandler:
      valkeyRdmaWrite → connRdmaHandleCq:
        ibv_get_cq_event() → EAGAIN (event_A 已被 T2 消费,没有新 event)
        ibv_req_notify_cq() → re-arm
        ibv_poll_cq() → 可能有 SEND 完成的 CQE
        ibv_poll_cq() → 0
      connRdmaSend() → 发出新请求
      return

      speculative read 被跳过 (rdma_in_speculative_read == 1)

  T6: 返回到 T4 的 readHandler → 退出
      rdma_in_speculative_read = 0

  T7: epoll_wait 等待
      CQ 在 T5 中被 re-arm 了
      comp_channel->fd 没有 pending event

      等待 server 回复新请求...
      server 回复 → CQE → event → comp_channel->fd readable → readHandler ✓

  这个也正常!新请求会产生新回复、新 CQE、新 event。

  那到底什么时候会出问题??

  让我再仔细想一种场景——connRdmaRegisterRx 的 SEND 完成和 server 发来的新 RegisterXferMemory 的时序。

  当 client 消费完 RX buffer 后,read_zc_done 调用 connRdmaRegisterRx,它发送一个 RegisterXferMemory 命令给 server。这个 SEND 操作使用 IBV_SEND_SIGNALED,会产生一个
   SEND completion CQE。同时 server 收到后会处理数据,然后可能也发送一个 RegisterXferMemory 回来(告诉 client 新的 TX buffer 地址)。

  如果这些 CQE 在下一次 connRdmaHandleCq 中被处理,但对应的 event 被消费掉了,而在那之后 server 的数据回复才到来——因为有 re-arm,新的回复 CQE 会产生新
  event。所以这也应该正常。

  我开始怀疑问题可能更微妙。让我想想 ibv_req_notify_cq 和 ibv_poll_cq 之间的真正竞态。

  根据 RDMA verbs 的 man page,标准用法是:

  while (1) {
      ibv_get_cq_event(comp_channel, &cq, &ctx);
      ibv_ack_cq_events(cq, 1);
      ibv_req_notify_cq(cq, 0);
      while (ibv_poll_cq(cq, 1, &wc) > 0) {
          // process wc
      }
  }

  这个 pattern 保证不会丢失 CQE 通知。关键是 ibv_req_notify_cq 在 ibv_poll_cq 之前。如果在 poll 期间新 CQE 到达,它会被 poll 取到,并且因为是在 arm
  之后到达的,所以也会产生一个 event。下次 ibv_get_cq_event 会得到这个 event。

  但问题是当前代码在 EAGAIN 分支中调用了 ibv_req_notify_cq 但没有先消费 event。如果之前的 arm 还有效(没有 CQE 到达过消耗它),那么再次 arm 是幂等的。如果之前的 arm
   已经被消耗了(有 CQE 到达),那么 event 应该在 comp_channel 队列中,ibv_get_cq_event 应该能读到它而不是返回 EAGAIN。

  除非 event 在上一次 ibv_get_cq_event 中已经被读走了,但对应的 CQE 在那次的 ibv_poll_cq 中被处理了。 这就是正常流程。

  我开始觉得问题可能不是经典的 RDMA lost wakeup,而是 benchmark 层面的逻辑问题。让我重新仔细看 resetClient → writeHandler 在 speculative read 递归中的行为:

  // speculative read 正在执行 (rdma_in_speculative_read == 1)
  // readHandler 处理了 reply
  // clientDone → resetClient:
  static void resetClient(client c) {
      aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE);
      aeDeleteFileEvent(el, c->context->fd, AE_READABLE);  // ← 注意:删除了 READABLE!
      writeHandler(el, c->context->fd, c, 0);  // 直接调用
      c->written = 0;
      c->pending = config.pipeline * c->seqlen;
  }

  在 writeHandler 中写完后:
  aeDeleteFileEvent(el, c->context->fd, AE_WRITABLE);
  aeCreateFileEvent(el, c->context->fd, AE_READABLE, readHandler, c);
  // speculative read 被跳过 (guard)
  return;

  所以返回到 resetClient,c->written = 0, c->pending = ... 被设置。然后返回到 readHandler 的 clientDone 之后:

  // readHandler:
      c->pending--;  // ← 但 resetClient 已经重置了 c->pending!
      if (c->pending == 0) {
          clientDone(c);
          break;
      }

  不对,看更仔细。clientDone 在 pending 检查之后调用的:

  while (c->pending) {
      valkeyGetReply(c->context, &reply);
      if (reply != NULL) {
          ...
          c->pending--;
          if (c->pending == 0) {
              clientDone(c);   // ← resetClient → writeHandler
              break;           // ← 跳出 while 循环
          }
      } else {
          break;  // 没有更多数据
      }
  }

  clientDone 调用 resetClient,resetClient 调用 writeHandler。writeHandler 中设置了新的 pending (通过 resetClient 在 writeHandler 返回后设置)。等等,resetClient
  是先调用 writeHandler,然后设置 c->written = 0 和 c->pending。

  但 writeHandler 已经在内部做了 c->start = ustime() 和 c->latency = -1! 然后 resetClient 又设置 c->pending。所以流程是:

  resetClient:
    1. aeDeleteFileEvent(WRITABLE)
    2. aeDeleteFileEvent(READABLE)
    3. writeHandler()  // 发送新请求,注册 READABLE
    4. c->written = 0
    5. c->pending = config.pipeline * c->seqlen  // 新值

  writeHandler 内部在 c->written == 0 时会设置 c->start 和增加 requests_issued。但此时 c->written 还是旧值(resetClient 还没执行到第 4 步)!看 resetClient 的源码:

  static void resetClient(client c) {
      ...
      if (config.ct == VALKEY_CONN_RDMA) {
          writeHandler(el, c->context->fd, c, 0);
      }
      c->written = 0;     // ← writeHandler 之后才设置
      c->pending = ...;
  }

  而 writeHandler:
  if (c->written == 0) {  // ← 此时 c->written 是什么?
      // 上一轮设置的值。如果上一轮 write 完成了,writeHandler 没有设置 c->written
      // cliWriteConn 返回 buf_len,c->written 没有被更新(因为走的是 buf_len == writeLen 的分支)
  }

  等等,让我看 writeHandler 写完后的逻辑。writeHandler 中 write 成功后不会更新 c->written。c->written 只在部分写入时更新 (c->written +=
  nwritten)。所以如果上一轮写完了(nwritten == writeLen),c->written 保持为 0(因为上一次 resetClient 设置了它为 0)。

  那在递归调用 writeHandler 时,c->written == 0,所以它会 atomic_fetch_add requests_issued。这是正确的。

  好吧,逻辑似乎没问题。让我回到 RDMA 层面,做最后一次精确分析。

  我现在认为问题可能是:connRdmaHandleCq 在 poll 到 RECV_IMM CQE 后更新了 rx_offset,但同时消费了 comp_channel 的 event。之后 speculative read
  确实读到了数据,但递归的 writeHandler 中的 connRdmaHandleCq 再次 re-arm CQ。如果此时 server 还没发回复(因为新请求刚发出),CQ 是 arm 的且为空。然后 speculative
  read guard 阻止了第二次 speculative read。回到 epoll_wait 时,comp_channel->fd 不可读。新回复到达时 CQE 产生 event,epoll_wait 返回——这是正常的。

  所以从纯逻辑角度看,每个场景似乎都能最终恢复。问题可能不是 lost wakeup(永久卡住),而是一种 性能抖动 导致的间歇低效。

  让我重新考虑:你说的 "RPS 为 0 一段时间然后又非常高" ——这个 "一段时间" 大约是多久?如果是 250ms 的倍数,那可能是 time event 触发 showThroughput 的间隔。

  最终发现:真正的问题在 valkeyRdmaWrite 中的阻塞 poll()!

  让我再看一遍 valkeyRdmaWrite:

  static ssize_t valkeyRdmaWrite(valkeyContext *c) {
      ...
      end = vk_msec_now() + timed;  // timed = INT_MAX (无 command_timeout)

  pollcq:
      if (connRdmaHandleCq(c) == VALKEY_ERR) return VALKEY_ERR;

      if (ctx->tx_offset == ctx->tx_length) goto waitcq;  // TX buffer 满了

      connRdmaSend() ...
      if (wrote == data_len) return data_len;  // 写完了

  waitcq:
      if (valkeyRdmaPollCqCm(c, end) == VALKEY_OK) goto pollcq;  // ← 阻塞 poll!
  }

  当 TX buffer 没有准备好时(tx_offset == tx_length 或者初始时 tx_length == 0),write 会进入阻塞 poll()。

  初始时 tx_length == 0(calloc 后的默认值)。tx_length 只在 server 发来 RegisterXferMemory 后才被设置。但连接建立后,client 调用 connRdmaRegisterRx 注册 RX
  buffer,server 收到后也会发送它的 RX buffer 信息给 client。所以在第一次 write 之前,client 应该已经收到了 server 的 TX buffer 信息。

  但在快速循环中呢?每当 client 消费完 1MB RX buffer,read_zc_done 调用 connRdmaRegisterRx 发送新 buffer 信息给 server。server 消费完自己的 1MB RX buffer
  后,也会重新注册并发送新的 TX buffer 信息给 client。

  关键场景:如果 client 发送了很多小请求(GET),server 很快回复,client 消费完了 TX buffer(1MB),但 server 还没来得及发新的 RegisterXferMemory,那 tx_offset ==
  tx_length,valkeyRdmaWrite 就会进入阻塞 poll()!

  但等等,GET 请求很小(几十字节),1MB 的 TX buffer 可以容纳几万个请求。在 pipeline=1 的情况下不应该那么快用完。

  除非 tx_offset 不会被重置直到 server 发来新的 RegisterXferMemory。让我确认:tx_offset 在每次 connRdmaSend 后增加 data_len。在 connRdmaHandleRecv 的
  RegisterXferMemory case 中 tx_offset = 0 重置。

  对于 pipeline=1 且 -c 1,每次只发一个 GET 请求(~30 字节),然后等回复。server 回复后 client 才发下一个请求。所以 TX buffer 不会累积。而且 server 消费完 TX buffer
   后会发新的 RegisterXferMemory。

  但 如果 server 的回复很大(比如 GET 的 value 很大)呢?不对,benchmark 的 -t get 获取的 key 可能不存在(返回 nil),或者 value 是之前 SET 设置的默认 3 字节。所以
  server 回复很小。

  让我重新看一下时间线,把所有的 connRdmaHandleCq 调用画出来:

  循环 N (pipeline=1, -c 1):

  1. resetClient → writeHandler:
     1a. valkeyRdmaWrite:
         connRdmaHandleCq()  \#CQ-1: 可能处理 SEND 完成、RegisterXferMemory 等
         connRdmaSend()  → 发出 GET 请求
         return
     1b. aeCreateFileEvent(READABLE, readHandler)
     1c. speculative readHandler:
         valkeyRdmaReadZC:
           早期检查: recv_offset < rx_offset?
           如果 CQ-1 处理了上一轮的 RECV_IMM → 可能有数据 → 返回
           如果没有 → connRdmaHandleCq() \#CQ-2
             → 如果 server 已回复,处理 RECV_IMM → 返回数据
             → 如果没有 → return 0
         如果有数据: readHandler 处理 → clientDone → 进入循环 N+1
         如果没有数据: 退出

  2. epoll_wait 等待 (如果 1c 没读到数据)
  3. comp_channel->fd readable → readHandler → 正常处理

  我终于想到了一个真正的问题场景!

  看 connRdmaHandleCq 在 EAGAIN 分支的行为(rdma.c:544-556):

  if (ibv_get_cq_event(...) < 0) {
      if (errno != EAGAIN) return VALKEY_ERR;
      ev_cq = ctx->cq;
  } else {
      ibv_ack_cq_events(ctx->cq, 1);
  }

  ibv_req_notify_cq(ev_cq, 0);  // re-arm

  当连续快速调用 connRdmaHandleCq 两次时(比如 write 中一次,speculative read 中一次),两次都走 EAGAIN 分支时,会做两次 ibv_req_notify_cq。这是幂等的。

  但如果第一次调用得到了 event(非 EAGAIN),ack 了它,re-arm 了,poll 了。第二次调用 EAGAIN(因为 event 已被消费),又 re-arm,又 poll。这也没问题。

  真正的问题场景:

  T1: connRdmaHandleCq (write path):
      ibv_get_cq_event() → EAGAIN
      ibv_req_notify_cq() → arm CQ
      ibv_poll_cq() → 0

  T2: connRdmaSend() → 发送 GET 请求 → 产生一个 unsignaled SEND WR
      (注意: send_flags 通常是 0,每 1024 个才是 SIGNALED)

  T3: speculative connRdmaHandleCq:
      ibv_get_cq_event() → EAGAIN (T1 的 arm 之后没有新 CQE)
      ibv_req_notify_cq() → arm (幂等)
      ibv_poll_cq() → 0 (server 还没回复)

  T4: 回到 epoll_wait
      CQ arm 了,等 server 回复...

  T5: server 回复 → RDMA WRITE WITH IMM →
      接收端 CQ 上产生 RECV_RDMA_WITH_IMM CQE
      → event 产生 (因为 CQ arm 了)
      → comp_channel->fd readable
      → epoll_wait 返回
      → readHandler → connRdmaHandleCq → 处理 CQE → 正常 ✓

  这个也正常。

  最后一个想法:也许问题在于 ibv_get_cq_event 在非阻塞 fd 上的 EAGAIN 是在当前 comp_channel 缓冲区为空时返回的。但 ibv_req_notify_cq 之后如果有一个 CQE 已经在 CQ
  中(在 arm 之前就在了),arm 之后的第一个新 CQE 才会产生 event。那个已经在 CQ 中的 CQE 需要被 ibv_poll_cq 取走。

  如果有这样的 CQE 存在,ibv_poll_cq 会取到它。但如果这个 CQE 是在 EAGAIN 分支中 ibv_req_notify_cq 和 ibv_poll_cq 之间产生的呢?

  ibv_req_notify_cq()  → arm
  --- CQE_X 到达 ---  → (arm 被消耗,event_X 产生)
  ibv_poll_cq() → CQE_X → 处理
  ibv_poll_cq() → 0

  // event_X 在 comp_channel 上
  // CQ 不再 arm(被 CQE_X 消耗了)
  // 需要重新 arm

  但代码不会重新 arm! connRdmaHandleCq 在 poll 完后直接返回,不会再次 arm。下一次调用 connRdmaHandleCq 时:

  ibv_get_cq_event() → event_X ← 消费
  ibv_ack_cq_events()
  ibv_req_notify_cq() → re-arm
  ibv_poll_cq() → 可能有新 CQE 或 0

  所以 event_X 会在下一次调用时被消费,CQ 会被重新 arm。这看起来也没问题。

  但关键问题是:下一次调用 connRdmaHandleCq 什么时候发生?

  如果 readHandler 回到 epoll_wait,而 comp_channel->fd 上有 event_X(readable),epoll_wait 会立即返回,调用 readHandler,调用 connRdmaHandleCq。这是正常的。

  但如果 speculative readHandler 在递归中已经通过早期检查(recv_offset < rx_offset)读取了数据,它不会调用 connRdmaHandleCq。然后递归的 writeHandler 中的
  connRdmaHandleCq 会消费 event_X。然后 speculative read guard 跳过了第二次 speculative read。此时 CQ 已被 re-arm,comp_channel 上没有 event。

  如果新请求的回复还没来,epoll_wait 等待——正常,新回复来了产生新 event。

  我现在倾向于认为纯逻辑上没有永久 lost wakeup,但存在一个性能问题:在某些时序下,speculative read guard 阻止了及时处理已缓冲的数据,导致不必要的 epoll_wait → 250ms
   timeout → 处理。

  让我验证一个场景。考虑 server 回复非常快(微秒级),benchmark 循环:

  writeHandler:
    valkeyRdmaWrite → connRdmaHandleCq:
      ibv_get_cq_event() → event_A (上一轮 server 回复的 event)
      ibv_ack_cq_events()
      ibv_req_notify_cq() → arm
      ibv_poll_cq() → CQE_RECV_IMM (上一轮 server 回复) + CQE_SEND (当前请求)
      // 等等 - 这里 ibv_poll_cq 取到了 RECV_IMM 但这轮还没发请求

  不对,这是 write path 中调用 connRdmaHandleCq,发请求之前。所以这里处理的 CQE 是之前遗留的。RECV_IMM 是 server 对上一轮请求的回复。

  writeHandler:
    valkeyRdmaWrite → connRdmaHandleCq:
      处理了上一轮的回复数据 (rx_offset 更新)
    connRdmaSend() → 发出新请求
    return

    aeCreateFileEvent(READABLE)
    speculative readHandler (rdma_in_speculative_read=1):
      valkeyRdmaReadZC:
        早期检查: recv_offset < rx_offset → YES! (write 的 connRdmaHandleCq 处理了数据)
        返回数据
      valkeyReaderFeed → valkeyGetReply → reply!
      pending-- → 0 → clientDone → resetClient → writeHandler(递归)

    递归 writeHandler:
      valkeyRdmaWrite → connRdmaHandleCq:
        ibv_get_cq_event() → EAGAIN (上面已经消费了)
        ibv_req_notify_cq() → arm
        ibv_poll_cq() → CQE_SEND (发送上一轮请求的完成)?
                         或者是新请求的 RECV_IMM?server 这么快回复了?
        如果 server 已经回复了:
          → CQE_RECV_IMM → rx_offset 更新
          ibv_poll_cq() → 0
      connRdmaSend() → 发出另一个新请求
      return

      aeCreateFileEvent(READABLE)
      speculative read 被跳过! (guard)

    返回到第一个 speculative read 的 readHandler:
      while (c->pending) → c->pending 已被 resetClient 重置为新值
      valkeyGetReply → 尝试从 reader 中读取 → 可能有数据也可能没有

      如果有数据 (reader 中还有上一次 feed 的残余?): 处理 → pending--
      如果没有: break

    readHandler 退出
    rdma_in_speculative_read = 0

  等一下!这里有一个关键问题! 当递归的 writeHandler 中 connRdmaHandleCq 处理了新请求的回复数据(rx_offset 更新),但 speculative read 被 guard
  跳过了。然后控制返回到第一个 readHandler 的 while 循环。

  此时 c->pending 已被 resetClient 重置为新的请求的 pending 数。但 reader buffer 中有新请求的回复数据吗?

  valkeyRdmaReadZC 返回数据后,valkeyBufferRead 调用 valkeyReaderFeed 将数据喂给 reader。然后 read_zc_done 可能重置 RX buffer。在第一次 readHandler 中处理完上一轮的
   reply 后,进入 clientDone → 递归。

  递归中 writeHandler 的 connRdmaHandleCq 可能消费了新回复的 CQE(rx_offset 更新),但没有人调用 valkeyBufferRead 来读取这个数据并 feed 给 reader。因为 speculative
  read 被跳过了。

  回到第一个 readHandler 的 while 循环时:
  - c->pending 是新值(比如 1)
  - reader 中没有新数据(因为 valkeyBufferRead 没被再次调用)
  - valkeyGetReply → valkeyNextInBandReplyFromReader → 返回 NULL
  - reply == NULL → break 退出 while

  然后 readHandler 退出。此时 recv_buf 中有数据(rx_offset 被更新了),但 reader 不知道!

  回到 epoll_wait。comp_channel->fd 是否可读?

  - 递归 writeHandler 的 connRdmaHandleCq 在 EAGAIN 分支 re-arm 了 CQ
  - 如果新请求的回复 CQE 在 arm 之后到达(被 poll 取走),event 已产生
  - 但这个 event 没有被 ibv_get_cq_event 消费(EAGAIN 分支),所以 event 还在 comp_channel 上

  等等,这取决于 CQE 是在 arm 之前还是之后到达的。让我仔细看:

  递归 writeHandler 的 connRdmaHandleCq:
    ibv_get_cq_event() → EAGAIN (无 event)
    ibv_req_notify_cq() → arm
    --- CQE_NEW_REPLY 到达 (arm 之后) → event_B 产生 ---
    ibv_poll_cq() → CQE_NEW_REPLY → rx_offset 更新
    ibv_poll_cq() → 0
    return

  event_B 在 comp_channel 上。回到 epoll_wait 时,fd 是 readable 的 → 立即返回 → readHandler → valkeyBufferRead:
  - valkeyRdmaReadZC 的早期检查: recv_offset < rx_offset → YES(data 在 recv_buf 中)
  - 返回数据 → feed reader → 解析 reply → 正常

  所以如果 event 存在,epoll 会立即返回,不会卡住。

  但如果 CQE 在 arm 之前就已经在 CQ 中呢?

  递归 writeHandler 的 connRdmaHandleCq:
    ibv_get_cq_event() → EAGAIN
    --- 此时 CQ 中已有 CQE_NEW_REPLY (在之前的 arm 之后产生的, event_B 已存在) ---
    哦不对,如果 event_B 已存在,ibv_get_cq_event 不应该返回 EAGAIN

  是的!如果有 pending event,ibv_get_cq_event 会返回它。所以如果返回 EAGAIN,说明没有 event,也意味着 CQ 中的 CQE(如果有的话)不是在 arm 之后产生的——它们不会产生
  event。

  但 ibv_poll_cq 还是会取到它们。取到后,event 不存在,CQ 的 arm 也没有被消耗(因为 CQE 是在 arm 之前就在的——不对,EAGAIN 说明之前的 arm 还有效,没有新 CQE
  消耗它)。

  实际上,EAGAIN 意味着之前的所有 event 都已被消费。如果 CQ 中有遗留 CQE,它们是在 arm 之前就存在的(所以没有产生 event),ibv_poll_cq 取到它们后 arm 状态不变。

  然后在 ibv_req_notify_cq() re-arm 之后,如果又有新 CQE 到达:
  - arm 有效 → 产生 event → comp_channel fd readable

  如果在 arm 和 poll 之间新 CQE 到达:
  - arm 被消耗,event 产生
  - poll 取走 CQE
  - 但 event 在 comp_channel 上未消费
  - CQ 不再 arm

  这就是关键!如果 poll 取走了 arm 后的 CQE 但 CQ 不再被 arm,下一个 CQE 不会产生 event!

  但代码中的 poll 是 drain-all 循环(goto pollcq),所以之后如果没有更多 CQE,就返回了。CQ 不再 arm。下一次 connRdmaHandleCq 时:

  ibv_get_cq_event() → event (上次 arm 后产生的, 未消费)
  ibv_ack_cq_events()
  ibv_req_notify_cq() → re-arm
  ibv_poll_cq() → ...

  但等等,event 已经在 comp_channel 上了(上次 arm-after-CQE 产生的),所以 ibv_get_cq_event 能读到它。那 comp_channel->fd 也是 readable 的,epoll_wait 会返回。

  所以理论上不应该有永久阻塞。event 总会在 comp_channel 上等着被读取。

  让我从根本上重新思考。也许问题不是 comp_channel event 丢失,而是 comp_channel->fd 被从 epoll 中删除了。

  看 resetClient:
  aeDeleteFileEvent(el, c->context->fd, AE_READABLE);  // ← 从 epoll 删除!

  然后 writeHandler 中:
  aeCreateFileEvent(el, c->context->fd, AE_READABLE, readHandler, c);  // ← 重新添加到 epoll

  但如果在 aeDeleteFileEvent 和 aeCreateFileEvent 之间,一个 event 到达了呢?

  epoll 使用 level-triggered 模式。如果 fd 被从 epoll 删除再添加,之前的 event 不会丢失——只要 fd 本身仍然是 readable 的,re-add 后 epoll_wait 会立即返回。

  所以这也不是问题。

  我开始怀疑问题可能真的更简单——也许是 valkeyRdmaReadZCDone 中 connRdmaRegisterRx 的影响。

  每当 recv_offset == recv_length (1MB buffer 用完) 时,read_zc_done 调用 connRdmaRegisterRx:
  ctx->rx_offset = 0;
  ctx->recv_offset = 0;

  这重置了指针。如果下一次 server 发来的数据在 client 重新注册之前到达——不可能,因为 server 还没收到新的 RegisterXferMemory。

  但 connRdmaRegisterRx 发送的是一个 SEND(rdmaSendCommand,使用 IBV_SEND_SIGNALED)。这个 SEND 的完成 CQE 会在 CQ 上。如果这个 CQE 是在 arm
  之后产生的,它会产生一个 event,使 comp_channel->fd readable。

  这可能导致虚假唤醒! readHandler 被调用,但 connRdmaHandleCq 只处理了 SEND 完成(不是 RECV_IMM),rx_offset 没有更新,valkeyRdmaReadZC 返回 0,readHandler
  无用地退出。

  但这不会导致卡顿——只是一次空轮。

  OK,经过极其深入的分析,我现在提出一个新的理论:

  问题出在 valkeyBufferRead 只调用一次 read_zc。如果 CQ 中有多个 RECV_IMM completion(server 发送了多个 RDMA WRITE),connRdmaHandleCq 的 drain-all
  循环会处理它们全部,rx_offset 会一次性增加很多。read_zc 返回所有这些数据,reader 一次性 feed 了很多数据。

  但关键是 read_zc_done 在 recv_offset == recv_length 时会调用 connRdmaRegisterRx 重置 rx_offset = 0, recv_offset = 0。而 read_zc 中设置了 recv_offset =
  rx_offset。所以如果 rx_offset 恰好等于 recv_length,read_zc_done 会重置一切。

  但如果 read_zc 中 connRdmaHandleCq 处理了多个 RECV_IMM,rx_offset 超过了 recv_length? 不会,因为有 assert: assert(byte_len + ctx->rx_offset <=
  ctx->recv_length)。

  到这里,我需要承认这个问题非常微妙。让我给出我的最终诊断和修复建议。

之后我们还要做PR的拆分
#

我们分成3个PR来做:

server crash
#

1.server crash,这个直接改src/rdma.c.

https://github.com/valkey-io/valkey/pull/3448

这个还需要通过,所以这个原理又是什么?

benchmark简化
#

2.benchmark,这个也是单独修改,这里相当于是做进一步的抽象,(就是benchmark.c配合在libvalkey中的代码进行修改.)

https://github.com/valkey-io/valkey/pull/3492

这里本质上是简化重构和做了简单的死锁修复.

我要在valkey上首先替换成最新的libvalkey,然后在我的分支上进行相关的测试即可.

但是我还是发现存在问题.

我们要使用什么测试指令:

Overall LGTM. would you please bump libvalkey into valkey manually, then test benchmark with 1K, 32k, 1M KV , then paste the test result?

# 1 KiB
./src/valkey-benchmark --rdma -h 10.0.0.1 -p 6379 -t set,get -n 100000 -c 32 -d 1024

# 32 KiB
./src/valkey-benchmark --rdma -h 10.0.0.1 -p 6379 -t set,get -n 50000 -c 16 -d 32768

# 1 MiB
./src/valkey-benchmark --rdma -h 10.0.0.1 -p 6379 -t set,get -n 10000 -c 4 -d 1048576

现在会直接卡住,我不知道是libvalkey的问题还是我们更改benchmark产生的问题?

~/Project/valkey fix/rdma-benchmark-speculative-read* ❯ ./src/valkey-benchmark --rdma -h 10.0.0.1 -p 6379 -t set,get -n 100000 -c 32 -d 1024

^CT: rps=0.0 (overall: 18.2) avg_msec=-nan (overall: 1.418) 32 requests

对照实验,在当前的unstable中,贴入最新的libvalkey,实验看是否会出现卡死的问题

在unstable分支上进行实验发现没有问题,应该就是我们的修改引入了bug,妈的,全是问题怎么修改啊.

其实可以直接进行控制,因为是subtree:

git fetch upstream
git checkout main
git reset --hard upstream/main    # 与 valkey-io 官方 main 完全一致(会丢掉未提交修改)
make clean && make USE_RDMA=1

libvalkey
#

3.libvalkey,单独在另一个仓库进行修改.

https://github.com/valkey-io/libvalkey/pull/301

本次libvalkey的问题交给pizhenwei来处理,我们进行验证:

@Ada-Church-Closure tried to fix this issue in another PR, I reviewed it and realized the reason of the lost POLLIN/EPOLLIN event, I implement a fix:

https://github.com/pizhenwei/valkey/tree/libvalkey-lost-event

I can’t reproduce the same issue on my platform, would you please try this version and paste the test result?

就是比如说,当你想验证别人的分支修改的时候,我想尝试看看别人的验证是否正确,或者给别人做code review的时候:

git remote add pizhenwei https://github.com/pizhenwei/valkey.git # 添加远程即可
git fetch pizhenwei libvalkey-lost-event

我们先查看一下当前分支上修改了什么?

git log pizhenwei/libvalkey-lost-event --oneline -5
git diff unstable..pizhenwei/libvalkey-lost-event

因为是配合修改的,我们首先需要创建一个测试的分支:

git checkout -b test-maintainer-libvalkey-fix pizhenwei/libvalkey-lost-event

然后从我们的分支上把整个文件valkey-benchmark.c都拿过来→你不能这么做,因为我们目前的版本已经出现了差异:

其实你只要把修改应用过去就OK了.