[{"content":" 这篇承接 《KV 进化（一）：给 91kvstore 接上一条跳表》。上一阶段完成了第四种存储引擎的接入，这一阶段继续解决一个更实际的问题：服务退出以后，内存中的数据怎样留下来？\n本文记录从 AOF 基础闭环，到全量快照，再到通过 AOF_OFFSET 连接二者的完整实现过程。\n1. 确立实现模式 项目采用混合持久化模式：\n全量快照：保存某一时刻完整的数据集。 AOF 增量日志：每次成功执行写命令后，记录这条命令。 AOF 的基础闭环为：\n执行写命令 → 写入 AOF → 服务重启 → 读取 AOF → 重新执行历史命令 → 恢复内存数据 加入全量快照以后，最终恢复流程变成：\n加载全量快照 → 获得快照对应的 AOF 偏移量 → 从偏移量开始重放 AOF → 恢复出最新数据 2. 建立独立的持久化模块 持久化属于独立的辅助模块，不应该把所有文件操作都堆在 kvstore.c 中，因此新增：\ninclude/persistence.h src/persistence/aof.c src/persistence/snapshot.c 其他模块通过：\n#include \u0026#34;persistence.h\u0026#34; 使用持久化功能。\n头文件中声明了 AOF 对外提供的接口：\nint kvs_aof_open(const char *path); int kvs_aof_append(char **tokens, int count); int kvs_aof_close(void); long long kvs_aof_get_offset(void); 为了实现重放，又增加了回调函数类型：\ntypedef int (*aof_replay_handler)( char *msg, int length, char *response ); 以及 AOF 重放接口：\nint kvs_aof_replay( const char *path, long long offset, aof_replay_handler handler ); 快照模块对外提供：\nint kvs_snapshot_save(const char *path); long long kvs_snapshot_load( const char *path, aof_replay_handler handler ); aof_replay_handler 是函数指针类型。重放函数读取到一条命令后，可以通过这个函数指针把命令交给 kvs_protocol() 执行。\n3. 打开和关闭 AOF AOF 文件使用一个模块内部的文件指针保存：\nstatic FILE *aof_fp = NULL; 打开文件时使用追加模式：\naof_fp = fopen(path, \u0026#34;a\u0026#34;); \u0026quot;a\u0026quot; 表示 append，也就是从文件末尾追加内容，不会覆盖已有日志。\nkvs_aof_open() 需要检查：\npath 是否为 NULL AOF 是否已经打开 fopen() 是否成功 约定返回值：\n0 成功 -1 失败 关闭时使用：\nint ret = fclose(aof_fp); aof_fp = NULL; 即使 fclose() 失败，也要把 aof_fp 设置为 NULL。因为调用 fclose() 以后，不应该继续使用原来的文件指针。\n4. 把命令追加到 AOF 协议层已经把命令切分到了 tokens 中。例如：\nSET name Jasper 切分以后是：\ntokens[0] = \u0026#34;SET\u0026#34; tokens[1] = \u0026#34;name\u0026#34; tokens[2] = \u0026#34;Jasper\u0026#34; count = 3 kvs_aof_append() 首先检查：\nAOF 文件是否已经打开 tokens 是否为 NULL count 是否为 2 或 3 对应位置的 token 是否存在 之后使用 fprintf() 把 tokens 重新组合成一条完整命令：\nfprintf(aof_fp, \u0026#34;%s %s\\n\u0026#34;, tokens[0], tokens[1]); 或者：\nfprintf(aof_fp, \u0026#34;%s %s %s\\n\u0026#34;, tokens[0], tokens[1], tokens[2]); 因此 AOF 文件中的内容类似：\nSET persist_key persist_value MOD persist_key new_value DEL persist_key 每条命令占一行，这样重放时才能确定不同命令之间的边界。\n5. 从缓冲区同步到文件 fprintf() 返回值大于等于 0，只能说明格式化写入没有在这一层报告错误。数据可能仍然位于 C 标准库维护的用户态缓冲区中。\n因此先调用：\nfflush(aof_fp); 它会把 C 标准库缓冲区中的数据交给操作系统。\n但是数据此时仍可能位于操作系统的页缓存中，所以继续调用：\nfsync(fileno(aof_fp)); 这里涉及两种文件表示：\nFILE * C 标准库使用的文件流 fd 操作系统使用的整数文件描述符 fsync() 接收文件描述符，所以需要使用：\nfileno(aof_fp) 把 FILE * 转换成对应的 fd。\n于是一次追加的顺序是：\nfprintf → fflush → fileno → fsync 其中任何一步失败，kvs_aof_append() 都返回 -1。\n6. 判断哪些命令需要写入 AOF AOF 只记录会改变数据的命令：\nSET DEL MOD 查询命令不需要记录：\nGET EXIST 当前项目中，每个数据结构引擎的命令都按照五个一组排列：\nSET GET DEL MOD EXIST 所以可以通过 cmd % 5 判断命令类型：\nstatic int kvs_is_write_command(int cmd) { if (cmd \u0026lt; KVS_CMD_START || cmd \u0026gt;= KVS_CMD_COUNT) { return 0; } switch (cmd % 5) { case 0: case 2: case 3: return 1; default: return 0; } } 对应关系是：\n0 → SET 1 → GET 2 → DEL 3 → MOD 4 → EXIST 7. 只有命令执行成功才记录 AOF 的写入位置是在业务命令执行完成、拿到 ret 之后。\n原因是：如果命令执行失败，却仍然被写进 AOF，那么下次重启时可能恢复出一个与用户实际操作结果不一致的数据集。\n当前判断为：\nif (!aof_replaying \u0026amp;\u0026amp; ret == 0 \u0026amp;\u0026amp; kvs_is_write_command(cmd)) { int aof_ret = kvs_aof_append(tokens, count); if (aof_ret \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to append AOF\\n\u0026#34;); exit(EXIT_FAILURE); } } 三个条件分别表示：\n!aof_replaying 当前不是重放阶段 ret == 0 业务命令执行成功 kvs_is_write_command(cmd) 当前是写命令 如果业务数据已经修改成功，但是 AOF 写入失败，服务选择立即退出。否则程序继续运行，就会出现：\n内存中修改成功 → AOF 中没有记录 → 服务重启 → 用户的数据丢失 8. AOF 重放 AOF 重放必须放在 KV 引擎初始化之后，因为历史命令最终要重新写进内存数据结构。\n同时，重放必须发生在以追加模式打开 AOF 之前。\n在只实现 AOF 的阶段，启动顺序是：\n初始化 KV 引擎 → 进入 AOF 重放状态 → 读取并执行历史 AOF → 退出 AOF 重放状态 → 以追加模式打开 AOF → 启动网络服务 aof_replaying 的含义是：\n0 → 正常运行，可以记录新的写命令 1 → 正在恢复历史数据，不能写入 AOF 注意，它表示“正在重放”，不是“正在重写”。\n它只能在函数外定义一次：\nstatic int aof_replaying = 0; 如果在 kvs_filter_protocol() 内再次定义同名变量，就会发生变量遮蔽。这样主函数修改的是外层变量，而协议处理函数读取的是内部变量，二者不是同一个变量。\n9. AOF 重放函数的实际过程 kvs_aof_replay() 使用只读模式打开 AOF：\nFILE *fp = fopen(path, \u0026#34;r\u0026#34;); 如果文件不存在，说明当前没有历史数据，可以直接返回成功：\nif (errno == ENOENT) { return 0; } 之后使用 getline() 每次读取一行：\nwhile ((line_length = getline(\u0026amp;line, \u0026amp;capacity, fp)) != -1) { 假设读取到：\nSET persist_key persist_value 之后，通过传入的回调函数执行它：\nchar response[1024] = {0}; int response_length = handler( line, (int)line_length, response ); 因为调用 kvs_aof_replay() 时传入的是：\nkvs_protocol 所以这次调用实际相当于：\nkvs_protocol(line, (int)line_length, response); 历史命令因此重新进入原来的协议层和业务层，不需要为重放重新写一套 SET、MOD、DEL 逻辑。\n当返回值有效，并且响应为：\nOK\\r\\n 说明这一条历史命令重放成功。否则重放失败，服务不继续启动。\n10. AOF 基础闭环 AOF 阶段已经测试：\nSET persist_key persist_value MOD persist_key new_value 服务重启以后执行：\nGET persist_key 能够得到：\nnew_value 同时，重放期间不会把历史命令再次追加到 AOF，所以文件不会随着每次重启产生重复日志。\n至此，AOF 的基础闭环完成。但是如果 AOF 中存在大量历史命令，每次重启都从第一条开始执行，恢复时间会越来越长。所以接下来需要实现全量快照。\n11. 开始实现全量快照 AOF 保存的是每一次成功执行的写命令，而全量快照保存的是某一个时刻内存中的完整数据集。\n例如执行过：\nSET name Jasper MOD name Sao AOF 中保存的是操作过程：\nSET name Jasper MOD name Sao 而快照只关心最终数据状态：\nSET name Sao 因此，保存快照时不能简单复制 AOF。我们需要遍历当前内存中的数据，把每一个有效的 key-value 写进快照文件。\n项目中存在多个 KV 引擎：\narray rbtree hash skiplist 每一种数据结构的遍历方式不同：\n数组需要遍历每一个槽位。 红黑树需要按照节点关系递归遍历。 哈希表需要遍历桶以及桶中的链表。 跳表需要沿着最底层链表遍历。 但是持久化模块不应该知道这些数据结构的内部实现。\n所以采用统一的设计：\n引擎负责找到 key 和 value → 持久化模块负责把 key 和 value 写入文件 12. 定义统一的访问函数 在 kvstore.h 中定义访问函数类型：\ntypedef int (*kvs_visit_handler)( const char *key, const char *value, void *context ); 这个函数指针表示：\n引擎每找到一组 key-value → 就调用一次 visitor → 把 key、value 和 context 交给 visitor 三个参数分别表示：\nkey 当前遍历到的键 value 当前遍历到的值 context 调用者额外传入的信息 每一个引擎分别提供自己的遍历函数：\nint kvs_array_foreach( kvs_array_t *inst, kvs_visit_handler visitor, void *context ); int kvs_rbtree_foreach( kvs_rbtree_t *inst, kvs_visit_handler visitor, void *context ); int kvs_hash_foreach( kvs_hash_t *inst, kvs_visit_handler visitor, void *context ); int kvs_skiplist_foreach( kvs_skiplist_t *inst, kvs_visit_handler visitor, void *context ); 虽然四个引擎内部的遍历方法不同，但是对外接口的形式相同。\n13. foreach 的职责 以数组引擎为例：\nint kvs_array_foreach( kvs_array_t *inst, kvs_visit_handler visitor, void *context) { if (inst == NULL || inst-\u0026gt;table == NULL || visitor == NULL) { return -1; } for (int i = 0; i \u0026lt; inst-\u0026gt;total; i++) { if (inst-\u0026gt;table[i].key == NULL || inst-\u0026gt;table[i].value == NULL) { continue; } int ret = visitor( inst-\u0026gt;table[i].key, inst-\u0026gt;table[i].value, context ); if (ret \u0026lt; 0) { return -1; } } return 0; } 每一次循环找到一组有效的 key-value，就调用一次：\nvisitor(key, value, context); 所以遍历函数本身没有耦合具体的文件写入逻辑。它只负责：\n找到数据 → 把数据交给 visitor 真正决定如何使用这些数据的是传进来的 visitor。\n14. context 的意义 快照写入函数不只需要 key 和 value，它还需要知道：\n应该写入哪个文件。 当前数据来自哪个引擎。 应该使用 SET、RSET、HSET 还是 SSET。 所以定义结构体：\ntypedef struct snapshot_write_context { FILE *fp; const char *command; } snapshot_write_context_t; 这里：\nfp 快照文件的文件指针 command 当前引擎对应的写命令 然后把这个结构体的地址作为 void *context 传入遍历函数：\nsnapshot_write_context_t ctx; ctx.fp = fp; ctx.command = \u0026#34;SET\u0026#34;; kvs_array_foreach( \u0026amp;global_array, snapshot_write_item, \u0026amp;ctx ); 此时 \u0026amp;ctx 被转换成了 void *。\n进入 snapshot_write_item() 以后，再把它转换回来：\nsnapshot_write_context_t *ctx = (snapshot_write_context_t *)context; 所以 context 并不是“一个数”。它是一个通用指针，这里保存的是 ctx 这个结构体的地址。\n15. 把一组 key-value 写进快照 真正负责写入文件的函数是：\nstatic int snapshot_write_item( const char *key, const char *value, void *context) { if (key == NULL || value == NULL || context == NULL) { return -1; } snapshot_write_context_t *ctx = (snapshot_write_context_t *)context; if (ctx-\u0026gt;fp == NULL || ctx-\u0026gt;command == NULL) { return -1; } int written = fprintf( ctx-\u0026gt;fp, \u0026#34;%s %s %s\\n\u0026#34;, ctx-\u0026gt;command, key, value ); if (written \u0026lt; 0) { return -1; } return 0; } 例如数组引擎中存在：\nname → Jasper age → 20 调用两次 snapshot_write_item() 后，快照中会得到：\nSET name Jasper SET age 20 也就是说，每遍历到一个有效数据，就执行一次 snapshot_write_item()。\n16. 为什么快照中只保存 SET 类型的命令 快照保存的是当前最终数据集，而不是数据变化的过程。\n例如：\nSET name Jasper MOD name Sao DEL age 最终只剩下：\nname → Sao 所以快照只需要保存一条能够重新创建当前数据的命令：\nSET name Sao 对于不同引擎，分别使用：\nSET 数组引擎 RSET 红黑树引擎 HSET 哈希引擎 SSET 跳表引擎 快照不需要保存 MOD 和 DEL。因为已经被删除的数据不会出现在遍历结果中，而被修改的数据会直接以最终值保存。\n17. 保存所有引擎的数据 在 snapshot.c 中访问四个全局引擎：\n#if ENABLE_ARRAY extern kvs_array_t global_array; #endif #if ENABLE_RBTREE extern kvs_rbtree_t global_rbtree; #endif #if ENABLE_HASH extern kvs_hash_t global_hash; #endif #if ENABLE_SKIPLIST extern kvs_skiplist_t global_skiplist; #endif 然后依次遍历已经启用的引擎：\n#if ENABLE_ARRAY if (result == 0) { ctx.command = \u0026#34;SET\u0026#34;; if (kvs_array_foreach( \u0026amp;global_array, snapshot_write_item, \u0026amp;ctx) \u0026lt; 0) { result = -1; } } #endif 其他引擎对应的命令为：\n红黑树 ctx.command = \u0026#34;RSET\u0026#34; 哈希表 ctx.command = \u0026#34;HSET\u0026#34; 跳表 ctx.command = \u0026#34;SSET\u0026#34; result 用于记录整个快照保存过程是否成功：\n0 到目前为止没有失败 -1 某一步已经失败 如果前一个引擎保存失败，后面的引擎就不再继续写。这样可以避免生成一个看起来正常、实际上缺少部分数据的快照。\n18. 使用临时文件保存快照 不能直接使用：\nfopen(path, \u0026#34;w\u0026#34;); 覆盖原来的正式快照。因为如果写到一半时程序崩溃，旧快照已经被破坏，新快照又没有写完整。\n所以先生成临时文件路径：\nchar temp_path[1024]; snprintf( temp_path, sizeof(temp_path), \u0026#34;%s.tmp\u0026#34;, path ); 例如：\n正式文件：snapshot.db 临时文件：snapshot.db.tmp 这里使用 snprintf()，是因为它可以接收缓冲区大小，避免写出的字符串超过 temp_path 的容量。\n然后使用 \u0026quot;w\u0026quot; 模式打开临时文件：\nFILE *fp = fopen(temp_path, \u0026#34;w\u0026#34;); \u0026quot;w\u0026quot; 表示重新写入。临时文件如果已经存在，原来的内容会被清空。\n19. 把快照同步到磁盘 所有引擎遍历完成以后，依次执行：\nfflush(fp) → fsync(fileno(fp)) → fclose(fp) 含义与 AOF 相同：\nfflush 把 C 标准库缓冲区交给操作系统 fsync 要求操作系统把内容同步到底层存储 fclose 关闭文件 如果任何一步失败：\nunlink(temp_path); return -1; unlink(temp_path) 的意思是删除失败的临时文件，不是关闭文件。文件已经由 fclose() 负责关闭。\n全部成功以后执行：\nrename(temp_path, path); 这一步把：\nsnapshot.db.tmp 替换成：\nsnapshot.db 因此，快照保存过程是：\n写临时文件 → 确认完整写入 → 同步到磁盘 → 关闭临时文件 → 用临时文件替换正式快照 这样可以尽量避免生成半份快照。\n20. 增加 SAVE 命令 在持久化头文件中暴露快照保存接口：\nint kvs_snapshot_save(const char *path); 协议层切分完 tokens 后，单独判断：\nif (count == 1 \u0026amp;\u0026amp; strcmp(tokens[0], \u0026#34;SAVE\u0026#34;) == 0) { int save_result = kvs_snapshot_save(\u0026#34;snapshot.db\u0026#34;); if (save_result \u0026lt; 0) { return sprintf(response, \u0026#34;ERROR\\r\\n\u0026#34;); } return sprintf(response, \u0026#34;OK\\r\\n\u0026#34;); } 用户发送：\nSAVE 程序就会把当前四个引擎中的完整数据保存到 snapshot.db。\nSAVE 本身不是修改 KV 数据的普通业务命令，所以不需要写进 AOF。\n21. 快照与 AOF 的重复问题 如果快照已经保存了当前全部数据，而服务启动时又从 AOF 开头执行所有命令，就会出现重复恢复。\n例如 AOF 中有：\nSET name Jasper MOD name Sao 快照中已经有：\nSET name Sao 启动时先读取快照，内存里已经存在 name。如果又从 AOF 开头执行 SET name Jasper，就可能因为 key 已经存在而失败。\n所以快照必须知道：\n保存这份快照时，AOF 已经写到了哪个位置。\n22. 获取 AOF 当前偏移量 在 AOF 模块中增加：\nlong long kvs_aof_get_offset(void); 实现过程：\nlong long kvs_aof_get_offset(void) { if (aof_fp == NULL) { return -1; } off_t offset = ftello(aof_fp); if (offset == (off_t)-1) { return -1; } return (long long)offset; } off_t 是系统用来表示文件位置和文件大小的类型。\nftello() 返回当前文件位置，也就是 AOF 已经写了多少字节。\n假设返回：\n189 表示保存快照时，AOF 的前 189 个字节已经被这份快照包含。\n23. 把 AOF 偏移量写进快照 保存快照之前先获取：\nlong long aof_offset = kvs_aof_get_offset(); 然后把它写在快照第一行：\nfprintf(fp, \u0026#34;AOF_OFFSET %lld\\n\u0026#34;, aof_offset); 最终快照类似：\nAOF_OFFSET 189 SET persist_key new_value SET snapshot_array value_array RSET snapshot_rbtree value_rbtree HSET snapshot_hash value_hash SSET snapshot_skiplist value_skiplist 第一行是元数据，不是业务命令。后面的每一行才是用来恢复数据的命令。\n24. 加载全量快照 在持久化头文件中增加：\nlong long kvs_snapshot_load( const char *path, aof_replay_handler handler ); 这个函数做两件事：\n1. 读取并执行快照中的数据命令 2. 返回快照记录的 AOF_OFFSET 使用只读方式打开：\nFILE *fp = fopen(path, \u0026#34;r\u0026#34;); 如果快照文件不存在，说明以前没有保存过快照：\nif (errno == ENOENT) { return 0; } 返回 0 表示：\n没有快照数据 → AOF 应该从第 0 个字节开始重放 25. 读取快照中的偏移量 首先使用 getline() 读取第一行，然后解析：\nlong long aof_offset = -1; int parsed = sscanf( line, \u0026#34;AOF_OFFSET %lld\u0026#34;, \u0026amp;aof_offset ); 这里：\nparsed 成功解析出的字段数量 aof_offset 真正保存偏移量的变量 如果第一行是：\nAOF_OFFSET 189 那么：\nparsed = 1 aof_offset = 189 如果没有成功读取一个偏移量，或者偏移量小于 0，就说明快照格式错误。\n26. 逐行恢复快照数据 读取完第一行以后，继续使用 getline() 循环读取剩余内容：\nwhile ((line_length = getline(\u0026amp;line, \u0026amp;capacity, fp)) != -1) { getline() 每执行一次，就从文件当前位置读取一行。\n读取完成以后，去掉末尾的换行符：\nwhile (line_length \u0026gt; 0 \u0026amp;\u0026amp; (line[line_length - 1] == \u0026#39;\\n\u0026#39; || line[line_length - 1] == \u0026#39;\\r\u0026#39;)) { line[--line_length] = \u0026#39;\\0\u0026#39;; } 然后准备一个响应缓冲区：\nchar response[1024] = {0}; 把快照命令交给协议函数：\nint response_length = handler( line, (int)line_length, response ); 因为传进来的 handler 是 kvs_protocol，所以实际效果相当于：\nkvs_protocol(line, (int)line_length, response); 例如快照中的：\nHSET snapshot_hash value_hash 会重新进入原来的协议解析和哈希引擎处理流程。\nresponse 不会发送给网络客户端，它只用于检查这条恢复命令是否返回：\nOK\\r\\n 所有快照命令恢复成功后，函数返回：\nreturn aof_offset; 27. 让 AOF 从指定位置开始重放 AOF 重放函数的接口改成：\nint kvs_aof_replay( const char *path, long long offset, aof_replay_handler handler ); 打开 AOF 后，先执行：\nfseeko(fp, (off_t)offset, SEEK_SET); 三个参数表示：\nfp 当前 AOF 文件 offset 要跳转到的字节位置 SEEK_SET 从文件开头计算位置 例如：\noffset = 189 就表示跳过 AOF 前 189 个字节，只重放后面的增量命令。\n这里并不是逐条判断哪些命令应该跳过，而是直接移动文件的读取位置。\n28. 最终启动恢复顺序 最终启动过程变成：\n初始化所有 KV 引擎 → 进入恢复状态 → 加载 snapshot.db → 得到 AOF_OFFSET → 从该偏移量开始重放 appendonly.aof → 退出恢复状态 → 以追加模式打开 AOF → 启动网络服务 代码大致为：\ninit_kvengine(); aof_replaying = 1; long long aof_offset = kvs_snapshot_load( \u0026#34;snapshot.db\u0026#34;, kvs_protocol ); if (aof_offset \u0026lt; 0) { aof_replaying = 0; fprintf(stderr, \u0026#34;failed to load snapshot\\n\u0026#34;); return -1; } int replay_ret = kvs_aof_replay( \u0026#34;appendonly.aof\u0026#34;, aof_offset, kvs_protocol ); aof_replaying = 0; if (replay_ret \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to replay AOF\\n\u0026#34;); return -1; } if (kvs_aof_open(\u0026#34;appendonly.aof\u0026#34;) \u0026lt; 0) { fprintf(stderr, \u0026#34;failed to open AOF\\n\u0026#34;); return -1; } 这里的 aof_replaying 虽然叫做 AOF 重放状态，但是现在它实际上覆盖了整个数据恢复阶段：\n加载快照期间 不允许写入 AOF 重放 AOF 期间 不允许写入 AOF 因为快照和 AOF 中的历史命令都会调用 kvs_protocol()。\n如果没有这个标志，恢复出来的历史命令又会被当成新的写命令追加到 AOF。\n29. 快照与 AOF 最终如何配合 可以把二者理解成：\nsnapshot.db 保存稳定的历史基础 appendonly.aof 保存快照之后发生的新变化 假设：\nAOF 前 189 字节 → 已经包含在 snapshot.db 中 AOF 第 189 字节以后 → 是保存快照之后执行的新命令 恢复时：\nsnapshot.db → 恢复保存快照时的完整数据 AOF_OFFSET 之后的 AOF → 补上保存快照以后发生的变化 因此最后恢复出的内存状态是：\n快照中的完整数据 + 快照之后的增量命令 = 服务退出前的最新数据 30. 最终验证 保存快照时的数据为：\nSET persist_key new_value SET snapshot_array value_array RSET snapshot_rbtree value_rbtree HSET snapshot_hash value_hash SSET snapshot_skiplist value_skiplist 执行：\nSAVE 快照记录：\nAOF_OFFSET 189 保存完成以后，再执行：\nSET after_snapshot from_aof 这条命令没有进入快照，只进入了 AOF 偏移量之后的增量部分。\n服务重启以后，输出顺序为：\n先执行快照中的五条命令 → 再执行 SET after_snapshot from_aof → 最后开始监听端口 客户端执行：\nGET after_snapshot 能够得到：\nfrom_aof 这说明：\n全量快照恢复成功 AOF 增量恢复成功 偏移量跳过成功 恢复期间没有重复写入 AOF 31. 当前完成的整体闭环 目前已经完成的混合持久化流程是：\n正常运行 → 成功的写命令立即追加到 AOF → 用户执行 SAVE → 遍历所有引擎 → 保存当前完整数据集 → 在快照中记录 AOF_OFFSET → 快照保存后继续记录新的 AOF → 服务退出或者崩溃 → 下次启动先加载快照 → 再重放 AOF_OFFSET 之后的日志 → 恢复出最新内存数据 → 开始对外提供服务 我的理解是：\nAOF 负责尽量不丢掉每一次数据变化 快照负责保存某一时刻完整的数据集， 并缩短启动时需要处理的历史范围 AOF_OFFSET 负责把快照和 AOF 连接起来 aof_replaying 负责避免恢复过程再次产生日志 handler 负责让历史命令复用原来的业务处理流程 foreach 负责遍历不同引擎中的有效数据 visitor 负责处理遍历得到的每一组 key-value context 负责向 visitor 传递文件指针和命令类型 至此，全量快照和 AOF 增量日志已经组成了一个完整的混合持久化闭环。\n","date":"2026-08-30T00:00:00+08:00","image":"/MyBlog/p/kv-evolution-snapshot-aof-persistence/cover.svg","permalink":"/MyBlog/p/kv-evolution-snapshot-aof-persistence/","title":"KV 进化（二）：Snapshot 全量快照 + AOF 增量日志"},{"content":" 这篇不打算深挖跳表算法，而是记录一个外部数据结构如何从“能够独立运行”，一步步变成 91kvstore 中真正可用的存储引擎。\n目前这个 KV 存储引擎已经支持三种数据结构：\n结构体数组 红黑树 哈希表 那么问题来了：能不能再加入一种新的存储引擎，并且让它像现有的三个引擎一样，通过统一接口和网络命令工作？\n这次选择的是——跳表。\n最终希望实现这样的效果：\nSSET name Jasper SGET name SMOD name Sao SEXIST name SDEL name 表面上看，只是多写一个 kvs_skiplist.c；但真正有价值的部分并不是手搓跳表，而是研究如何把一个外部数据结构完整地融合进现有项目。\n先对跳表有个大致印象 开始之前，我先看了一些简短的跳表介绍，比如这个视频：\nB站：跳表相关介绍\n现在并不打算深入研究跳表的每一个概率细节，只需要先形成几个基本认知：\n跳表的底层仍然是一条有序链表； 它在普通链表上增加了多层索引； 查询时可以从高层快速跳过大量节点； 查找、插入、删除的平均时间复杂度是 O(log n)。 在工程中使用一种数据结构时，确实可以把它当成一个黑盒，但也不能完全不理解。至少要知道：\n它需要什么样的输入； 它如何管理内存； 它能提供哪些操作； 它的节点和数据由谁创建、由谁释放。 理解到这个程度，就足够开始融合了。\n写跳表之前，我先整理了项目 我多少有点目录洁癖。\n原来的 .c、.o、可执行文件和头文件全部堆在项目根目录，看着实在难受。因此没有立刻开始写跳表，而是先参照常见开源项目的结构，把工程重新整理了一遍：\n91kvstore/ ├── .gitignore ├── .gitmodules ├── Makefile │ ├── clients/ │ ├── go-kvstore.go │ ├── javakvstore.java │ ├── js-kvstore.js │ ├── py-kvstore.py │ └── rust-kvstore.rs │ ├── include/ │ ├── kvstore.h │ └── server.h │ ├── src/ │ ├── kvstore.c │ ├── engines/ │ │ ├── kvs_array.c │ │ ├── kvs_hash.c │ │ ├── kvs_rbtree.c │ │ └── kvs_skiplist.c │ │ │ └── network/ │ ├── ntyco.c │ ├── proactor.c │ └── reactor.c │ ├── tests/ │ └── testcase.c │ ├── third_party/ │ └── NtyCo/ # Git 子模块 │ ├── build/ # 编译产物，不提交 │ ├── kvstore.o │ ├── reactor.o │ ├── proactor.o │ ├── ntyco.o │ ├── kvs_array.o │ ├── kvs_hash.o │ ├── kvs_rbtree.o │ └── kvs_skiplist.o │ └── bin/ # 可执行文件，不提交 ├── kvstore └── testcase 这样一来，各部分的职责就很直观：\ninclude/ 放公共声明； src/engines/ 放存储引擎； src/network/ 放网络模型； tests/ 放测试程序； third_party/ 放第三方依赖； build/ 和 bin/ 只保存编译产物。 规整多了，right？\n第一步：把整数跳表改造成字符串 KV 找到的简易跳表实现只支持：\nint key; int value; 但 KV 存储引擎需要的是：\nchar *key; char *value; 因此节点最终变成：\ntypedef struct kvs_skiplist_node { char *key; char *value; struct kvs_skiplist_node **forward; } kvs_skiplist_node_t; 这里不只是把 int 改成 char * 那么简单。\n整数可以直接比较：\nnode-\u0026gt;key \u0026lt; key 字符串必须使用：\nstrcmp(node-\u0026gt;key, key) \u0026lt; 0 同时，字符串不能只保存调用方传进来的地址。节点需要为 key 和 value 单独申请内存并复制内容，否则调用方的字符串失效后，跳表里保存的指针也会失效。\n创建一个节点，大致需要依次完成：\n为节点结构体申请内存； 为 key 申请内存； 复制 key； 为 value 申请内存； 复制 value； 为每层 forward 指针申请空间。 每一步都可能失败。\n如果中途失败，就必须把前面已经申请成功的内存释放掉。否则节点虽然没有创建成功，却会留下内存泄漏。\n这部分看起来啰嗦，但它实际上比跳表算法本身更接近真实的 C 工程开发。\n第二步：补齐统一接口 原始代码只有插入、查询和展示，但项目中的每个存储引擎都需要提供七个接口：\nkvs_skiplist_create kvs_skiplist_destory kvs_skiplist_set kvs_skiplist_get kvs_skiplist_del kvs_skiplist_mod kvs_skiplist_exist 其中比较复杂的是：\nset：查找插入位置，并更新多层指针； get：沿索引层逐级查找； del：不仅要释放节点，还必须先从每一层中摘除节点。 mod 和 exist 相对简单，因为它们都可以复用内部查询函数：\nstatic kvs_skiplist_node_t * skiplist_search_node(kvs_skiplist_t *inst, const char *key); exist 只需要判断查询结果是否为 NULL。\nmod 则是在找到节点后，先申请并复制新的 value；成功之后再释放旧 value。这个顺序非常重要：\n先申请新内存 ↓ 申请成功 ↓ 释放旧 value ↓ 替换指针 如果先释放旧 value，再发现新内存申请失败，原来的数据也就丢了。\n第三步：统一返回值 接口能工作还不够，它必须遵守现有引擎的返回规则。\n最终统一为：\n返回值 含义 0 操作成功，或者 key 存在 1 key 重复，或者 key 不存在 -1 参数或实例不合法 -2 内存申请失败 具体含义需要结合接口理解。\n例如：\nkvs_skiplist_set(...) 返回 1 表示 key 已经存在。\n而：\nkvs_skiplist_del(...) 返回 1 表示 key 不存在。\n统一返回规则非常重要，因为协议层并不关心跳表内部发生了什么，它只根据返回值生成响应：\n0 → OK 1 → EXIST 或 NO EXIST \u0026lt; 0 → ERROR 第四步：别让测试变成纯苦力 最开始，每修改一个函数，就在 main() 中增加一堆 if/else，重新编译、运行、检查输出。\n写到后面发现，大量测试代码无非是在做：\n实际返回值是否等于预期返回值？ 于是写了一个辅助函数：\nstatic void check_test(int condition, const char *testName) { if (condition) { printf(\u0026#34;[PASS] %s\\n\u0026#34;, testName); } else { printf(\u0026#34;[FAIL] %s\\n\u0026#34;, testName); } } 测试就能简化为：\nresult = kvs_skiplist_set( skipList, \u0026#34;Dad\u0026#34;, \u0026#34;Jasper\u0026#34; ); check_test( result == 0, \u0026#34;set new key\u0026#34; ); 开发接口期间，可以先用 #if 0 屏蔽 main()，每完成一批接口只进行对象文件编译：\ngcc -Iinclude \\ -c src/engines/kvs_skiplist.c \\ -o /tmp/kvs_skiplist.o 等全部接口完成后，再临时启用 main()，统一进行一次完整测试。\n这样既能及时发现编译错误，也不用每改一个函数就维护一遍测试流程。\n第五步：从“能运行”走向“融入项目” 底层接口通过测试，只能说明跳表自己能工作。接下来才是真正的工程融合。\n首先，把公共内容移动到 include/kvstore.h：\n最大层数定义； 跳表节点类型； 跳表实例类型； 七个公开接口声明。 而这些内部辅助函数仍然留在 .c 文件中，并使用 static 隐藏：\nskiplist_create_node skiplist_random_level skiplist_search_node 这样外部模块只知道跳表“能做什么”，不需要知道它“具体怎么做”。\n接着，把直接使用的：\nmalloc free 替换为项目统一封装的：\nkvs_malloc kvs_free 再把：\nbuild/kvs_skiplist.o 加入 Makefile，让跳表参与整个项目的编译与链接。\nMakefile 的具体语法还需要继续学习，但目前已经能看懂它最核心的关系：\n源文件 ↓ 编译 对象文件 ↓ 链接 可执行文件 第六步：创建全局跳表实例 服务器需要一个长期存在的跳表实例，保存所有客户端写入的数据。\n因此在 kvs_skiplist.c 中定义：\nkvs_skiplist_t global_skiplist; 在 kvstore.c 中声明：\nextern kvs_skiplist_t global_skiplist; 这里的 extern 并不会创建第二个跳表，它只是告诉 kvstore.c：\n这个变量在其他文件中已经定义，你可以直接使用它。\n然后把跳表加入存储引擎生命周期：\nkvs_skiplist_create(\u0026amp;global_skiplist); 服务器退出时：\nkvs_skiplist_destory(\u0026amp;global_skiplist); 至此，跳表拥有了完整的生命过程：\n定义 → 初始化 → 处理请求 → 销毁 第七步：命令协议融合 最后，也是最关键的一步，是让客户端真的能够使用跳表。\n加入五条新命令：\nSSET SGET SDEL SMOD SEXIST 项目并不存在一个“当前使用哪个 engine”的开关，而是通过命令前缀直接选择引擎：\nSET → array RSET → rbtree HSET → hash SSET → skiplist 例如客户端发送：\nSSET Dad Jasper 服务器的处理过程是：\n解析 SSET ↓ 匹配 KVS_CMD_SSET ↓ 进入 switch 对应分支 ↓ 调用 kvs_skiplist_set() ↓ 根据返回值生成 OK / EXIST / ERROR 这一步完成后，跳表就不再是一个孤立的 .c 文件，而是真正成为了 KV 存储引擎的一员。\n最后：用 testcase 做端到端测试 底层 main() 测试的是跳表接口，而 tests/testcase.c 测试的是完整链路：\n测试客户端 → TCP → 网络层 → 协议解析 → global_skiplist → 跳表接口 → 协议响应 测试程序会自动发送：\nSSET SGET SEXIST SMOD SDEL 然后将服务器的实际响应与预期结果比较：\n相同 → PASS 不同 → FAILED 相比手动输入命令，这种测试不仅省事，还能在后续修改代码时快速确认：跳表功能有没有被意外破坏。\n小结 这次表面上是在“增加一个跳表”，实际上完整经历了一个新模块接入现有项目的过程：\n选择基础实现 → 改造成字符串 KV → 补齐统一接口 → 统一返回规则 → 完成底层测试 → 暴露公共声明 → 接入内存管理 → 接入 Makefile → 创建全局实例 → 接入生命周期 → 接入命令协议 → 完成端到端测试 跳表算法当然重要，但这次更重要的收获是：\n一个数据结构能独立运行，和它能成为项目中真正可用的一部分，是两件完全不同的事。\n前者解决算法问题，后者解决接口、内存、构建、生命周期、协议和测试问题。\n而后面这些，才是这次“KV 进化”真正有意思的地方。\n","date":"2026-08-28T00:00:00+08:00","image":"/MyBlog/p/kv-evolution-skiplist-engine/cover.svg","permalink":"/MyBlog/p/kv-evolution-skiplist-engine/","title":"KV 进化（一）：给 91kvstore 接上一条跳表"},{"content":" 阅读目标：在已有 Array、RBTree 和统一协议的基础上，重点复盘 Hash 如何真正成为一个可用的 KV 引擎；随后理解多语言客户端与服务端的边界，并按同一套接入模式继续扩展 SkipList。\n本文不讲 Hash 数据结构算法本身，重点是 Hash 与现有 KV 项目的融合。\n与前三篇的关系 本文承接以下三篇已有笔记：\n《KV 存储项目网络层》已经讲过 Reactor、Proactor、NtyCo 和 msg_handler 回调。 《KV 存储项目：协议层与数组存储》已经讲过 TCP 消息边界、token 解析、协议响应和 Array 的五种操作。 《KV 存储项目：客户端测试、压力测试与多引擎扩展》已经讲过 Makefile 基础、测试用例、压力测试、宏开关、5+2 接口以及 RBTree 的接入模式。 因此本文不再重复网络收发、协议解析、Array/RBTree CRUD、通用测试方法和 5+2 的基础概念，只保留理解 Hash 接入所需的连接点。\n1. 本阶段增加了什么 项目之前已经完成：\n统一网络入口 → 文本协议解析 → Array 引擎 → RBTree 引擎 → 客户端测试和压力测试 本阶段增加三项能力：\n1. 把 Hash 接入现有 KVEngine 2. 用 Go / Java / Node.js / Python / Rust 操作同一个 KV 服务 3. 按相同模式接入 SkipList，并为范围查询做准备 Hash 部分最值得掌握的不是“冲突链表怎样写”，而是：已经有一份 Hash 代码以后，还要修改项目的哪些位置，它才能从客户端真正访问到？\n类型与接口声明 → 编译和链接 → 全局实例 → create 初始化 → 协议命令分发 → set/get/del/mod/exist → 客户端测试 → destroy 释放 任何一环缺失，Hash 都没有完整融入项目。\n2. Hash 在项目中的位置 Hash 接入后，核心关系变成：\nkvs_protocol() │ kvs_filter_protocol() │ ┌─────────────────┼─────────────────┐ │ │ │ Array RBTree Hash 普通命令前缀 R 命令前缀 H 命令前缀 │ │ │ global_array global_rbtree global_hash 网络层仍然只调用 kvs_protocol()，不需要为了 Hash 修改 Reactor、Proactor 或 NtyCo。\n协议层也不需要知道桶、哈希下标和冲突链表，只负责把 Hash 命令映射到 kvs_hash_set/get/del/mod/exist。\n这体现了已有分层设计带来的扩展性：新增存储引擎，主要修改核心层和构建层，不侵入网络层。\n3. 第一个接入点：kvstore.h 3.1 增加编译开关 #define ENABLE_HASH 1 它应当像 ENABLE_ARRAY、ENABLE_RBTREE 一样，控制以下内容是否参与编译：\nHash 类型定义； Hash 函数声明与实现； global_hash； Hash 协议分支； Hash 的初始化与销毁。 宏开关的目的不是只隐藏一个结构体，而是完整地装上或拆下整个引擎。\n3.2 Hash 实例在项目中的最小理解 typedef struct hashnode_s { char *key; char *value; struct hashnode_s *next; } hashnode_t; typedef struct hashtable_s { hashnode_t **nodes; int max_slots; int count; } kvs_hash_t; 从 KV 项目角度只需知道：\nglobal_hash ├── nodes 桶数组，每个元素是一条冲突链的头指针 ├── max_slots 桶数量 └── count 当前键值对数量 nodes 是二级指针，因为它先表示桶数组，再由每个桶元素指向节点：\nnodes[0] → node → node → NULL nodes[1] → NULL nodes[2] → node → NULL 发生 Hash 冲突时，相同桶下标的节点通过 next 串在一起。这里只把它作为理解资源释放和实例生命周期的前提，不展开 Hash 算法。\n3.3 对外仍然使用已有的 5+2 形状 int kvs_hash_create(kvs_hash_t *hash); void kvs_hash_destroy(kvs_hash_t *hash); int kvs_hash_set(kvs_hash_t *hash, char *key, char *value); char *kvs_hash_get(kvs_hash_t *hash, char *key); int kvs_hash_del(kvs_hash_t *hash, char *key); int kvs_hash_mod(kvs_hash_t *hash, char *key, char *value); int kvs_hash_exist(kvs_hash_t *hash, char *key); 5+2 的设计价值和 RBTree 接入方式已在上一篇说明。这里的新增认识是：Hash 内部可以完全不同，但仍然服从项目已经形成的引擎契约。\n4. 第二个接入点：Makefile 本阶段开始把每个 .c 文件单独编译成 .o。Hash 必须进入对象文件集合：\nOBJS = kvstore.o reactor.o proactor.o kvs_array.o \\ ntyco.o kvs_rbtree.o kvs_hash.o kvs_hash.o: kvs_hash.c kvstore.h $(CC) $(CPPFLAGS) $(CFLAGS) -c kvs_hash.c -o kvs_hash.o kvstore: $(OBJS) $(CC) -o $@ $(OBJS) $(LDFLAGS) $(LDLIBS) 原笔记中的 OBJS 没有 kvs_hash.o。这意味着即使 kvs_hash.c 已经写好，最终的 kvstore 也没有编译、链接 Hash 实现。\n建议把参数按职责拆开：\nCC = gcc CPPFLAGS = -I./NtyCo/core CFLAGS = -Wall -Wextra -g LDFLAGS = -L./NtyCo LDLIBS = -luring -lntyco -lpthread -ldl 相对上一篇 Makefile 笔记，本阶段只需新增一个检查点：\n每增加一个 KV 引擎，都要确认它的目标文件同时进入编译规则和最终的 OBJS 链接集合。\n如果还要分别生成 kvstore、NtyCo 库和 testcase，可设为三个明确目标：\n.PHONY: all ntyco clean all: ntyco kvstore testcase ntyco: $(MAKE) -C NtyCo testcase 应有自己的对象文件和链接规则，不要与服务端的 main() 混在同一个目标中。\n5. 第三个接入点：实例生命周期 5.1 定义全局实例 #if ENABLE_HASH static kvs_hash_t global_hash; #endif 它与已有的 global_array、global_rbtree 并列，是 Hash 引擎在当前进程中的状态容器。\n5.2 网络服务启动前创建 #if ENABLE_HASH if (kvs_hash_create(\u0026amp;global_hash) != 0) { /* 初始化失败，不能继续提供 Hash 命令 */ } #endif 必须先创建引擎，再进入网络循环。否则客户端已经能够发来 HSET，协议层却会访问尚未初始化的桶数组。\n5.3 网络服务停止后销毁 #if ENABLE_HASH kvs_hash_destroy(\u0026amp;global_hash); #endif 完整顺序为：\n创建 Array / RBTree / Hash ↓ 启动所选网络层 ↓ 处理客户端命令 ↓ 网络层退出 ↓ 销毁 Hash / RBTree / Array 如果网络启动函数永不返回，还需要设计退出信号和优雅关闭流程，否则 destroy 虽然写在 main() 中，正常运行时却永远不会执行。\n6. 第四个接入点：协议命令 上一篇已经为 RBTree 增加 RSET/RGET/RDEL/RMOD/REXIST。教学阶段为了让多个引擎同时存在，可以继续采用同一规则：\nArray RBTree Hash 语义 SET RSET HSET 新增键值对 GET RGET HGET 查询 value DEL RDEL HDEL 删除键值对 MOD RMOD HMOD 修改已有 value EXIST REXIST HEXIST 判断 key 是否存在 协议层识别到 HSET 后调用：\nkvs_hash_set(\u0026amp;global_hash, tokens[1], tokens[2]); 其余命令同理。协议层负责将引擎内部返回值翻译成项目已有响应：\nOK\\r\\n EXIST\\r\\n NO EXIST\\r\\n 具体 value\\r\\n ERROR\\r\\n 需要保持的边界是：\n协议层：参数数量、命令选择、响应文本 Hash 层：key/value 保存、查询、修改、删除、资源管理 协议层不能直接访问 global_hash.nodes，否则 Hash 内部实现一改，协议代码也必须跟着改，统一接口就失去了意义。\n一条 HSET 的新增路径 前三篇已经讲过网络路径和 SET 完整路径，此处只看 Hash 分支的差异：\nHSET Teacher King → 协议层识别 HSET → 检查 token 数量为 3 → kvs_hash_set(\u0026amp;global_hash, \u0026#34;Teacher\u0026#34;, \u0026#34;King\u0026#34;) → 返回内部状态码 → 协议层生成 OK / EXIST / ERROR 从网络层到 token 切分的部分与以前完全相同，不需要为 Hash 重写。\n7. 当前 Hash 代码必须修正的工程问题 这些问题的重点是内存所有权和项目稳定性，而不是 Hash 算法。\n7.1 创建后必须把桶数组清零 hash-\u0026gt;nodes = kvs_malloc(sizeof(hashnode_t *) * hash-\u0026gt;max_slots); memset(hash-\u0026gt;nodes, 0, sizeof(hashnode_t *) * hash-\u0026gt;max_slots); kvs_malloc 返回的内存默认包含不确定值。不清零时，第一次读取 hash-\u0026gt;nodes[idx] 就可能取得随机地址。\n7.2 下标计算应使用实例容量 当前各操作固定使用 MAX_TABLE_SIZE：\nint idx = _hash(key, MAX_TABLE_SIZE); 应改为：\nint idx = _hash(key, hash-\u0026gt;max_slots); 否则 max_slots 字段形同虚设，将来改变容量或扩容时也会出错。\n7.3 创建节点失败要逆序回滚 指针模式下，一个节点的申请顺序是：\nnode → key 副本 → value 副本 如果 value 申请失败，必须释放已经成功申请的 key 和 node。当前代码释放的是本来就为 NULL 的 kvalue，遗漏了前两块内存。\nkvs_hash_set() 也要判断 _create_node() 是否返回 NULL，不能直接执行 new_node-\u0026gt;next。\n7.4 三条释放路径必须一致 开启 ENABLE_KEY_POINTER 后，一个节点拥有三块内存：\nnode ├── key └── value 以下三条路径都必须依次释放 key、value、node：\n删除桶的头节点； 删除桶内的普通节点； 销毁整张 Hash 表。 当前普通节点删除路径释放完整，但头节点删除和 destroy 只释放了 node，会发生内存泄漏。\n7.5 MOD 要先申请，再替换 当前代码先 free(node-\u0026gt;value)，然后申请新 value。如果申请失败，旧值已经丢失，节点中还留下悬空指针。\n安全顺序是：\n申请新 value → 复制成功 → 保存 old_value → node-\u0026gt;value = new_value → free(old_value) 这样内存申请失败时，旧值仍然有效。\n7.6 其他接口一致性问题 kvs_hash_mod() 还要检查 value != NULL。 固定数组模式下，strncpy 后必须保证字符串以 \\0 结束。 kvs_hash_count() 已实现，但头文件中缺少声明。 destory 应逐步统一为正确拼写 destroy。 kvs_hash_set() 的参数类型建议统一写成 kvs_hash_t *。 exist 的 0=存在、1=不存在 必须与 Array、RBTree 和协议响应保持一致。 销毁后应将 nodes=NULL、max_slots=0、count=0，降低误用风险。 8. 多语言支持：本阶段新增的认识 上一篇已经介绍过 TCP 客户端测试，第二篇也解释过消息边界，因此这里不再重复 socket 收发和粘包、拆包原理。\n本阶段要建立的新认识是：\nGo、Java、Node.js、Python 和 Rust 并不直接调用 C 的 Hash 函数；它们都是同一个文本协议的客户端。\n语言 TCP 客户端 API 发出的内容 Go net.Dial UTF-8 协议文本 Java Socket UTF-8 协议文本 Node.js net.createConnection UTF-8 协议文本 Python socket.socket UTF-8 协议文本 Rust TcpStream UTF-8 协议文本 五种语言的差异只在 socket API，服务端看到的都应该是：\nHSET Teacher King\\r\\n HGET Teacher\\r\\n 因此“支持第三方语言”的核心不是编写五套服务端适配层，而是稳定以下契约：\n服务器地址和端口； UTF-8 编码； 请求结束标记； 命令名称和参数数量； 响应格式； 连接是单请求、长连接还是支持流水线； 超时与异常处理方式。 原示例中的 IP 地址有两组：172.16.145.132 和 192.168.243.131，实际测试时应统一为配置项。\n用同一场景验证五种语言 不要让五份示例各自测试不同命令。它们应执行同一条状态链：\nHSET lang:test C → OK HGET lang:test → C HMOD lang:test common → OK HGET lang:test → common HEXIST lang:test → EXIST HDEL lang:test → OK HGET lang:test → NO EXIST 这样验证的是“协议与 Hash 引擎对所有语言行为一致”，而不只是证明五种语言都能建立 TCP 连接。\n正式测试还应给每个客户端加入：\nsend_all/write_all 语义，防止一次发送没有写完； 按 \\r\\n 或长度字段读取完整响应； 连接和读取超时； 非 UTF-8 或异常响应处理； 失败时返回非零退出码，便于自动化测试发现失败。 9. 课后扩展：把 SkipList 融入 KVEngine 通常称为 Skip List（跳表），不是 SkipTable。\n这道动手题可以直接复用 Hash、RBTree 的接入模板：\n1. kvstore.h 增加 ENABLE_SKIPLIST 2. 定义 kvs_skiplist_t 3. 声明 SkipList 的 5+2 接口 4. 新增 kvs_skiplist.c 5. Makefile 加入 kvs_skiplist.o 6. 核心层定义 global_skiplist 7. 启动时 create，退出时 destroy 8. 协议层增加 SSET/SGET/SDEL/SMOD/SEXIST 9. 复制已有状态链，编写 SkipList 端到端测试 10. 检查失败回滚、删除和销毁时的内存所有权 这能完成“把 SkipList 当作普通 KV 引擎接入”，但还没有发挥它的有序性。\n9.1 范围查询需要扩展 5+2 原有 5+2 只表达单个 key 的操作，不能表达范围查询。可以增加：\nint kvs_skiplist_range( kvs_skiplist_t *inst, const char *start_key, const char *end_key, kvs_pair_t *out, size_t capacity, size_t *count ); 协议层增加：\nSRANGE user:100 user:200 LIMIT 50 设计范围接口时要明确：\n起止 key 是否包含； key 按字符串还是数值排序； 最大结果数量； 输出缓冲区由谁分配和释放； 大结果集如何分页； 遍历期间发生修改时怎样保证一致性。 Hash 适合精确点查，但没有顺序；SkipList 和 RBTree 保持 key 有序，更适合范围查询。\n10. 面试题 10.1 KV 存储为什么常采用单线程执行命令？ 首先修正问题前提：KV 存储不是必须使用单线程。\n教学项目或某些内存 KV 的核心命令路径采用单线程，主要因为：\n非阻塞 I/O 可以让一个事件循环管理大量连接； 内存 KV 的单次操作通常很短； 串行执行避免共享数据上的锁竞争； 不需要频繁进行线程上下文切换； 命令顺序和数据一致性更容易理解与调试。 它的限制也很明显：慢命令会阻塞后续请求，CPU 密集型任务无法充分利用多核，单实例吞吐最终受单核限制。\n常见扩展方式是按 key 分片，每个分片由单线程串行执行，多个分片并行利用多核；也可以让网络 I/O、持久化、内存回收由不同线程负责。\n结合当前项目回答时还要看所选网络模型：如果多个线程可能同时调用协议处理函数并访问同一个 global_hash，Hash 就需要互斥锁、分片锁或单写者队列。不能因为包含了 pthread.h，就认为数据已经线程安全。\n10.2 范围查询应选择什么 KVEngine？ 范围查询要求 key 有序：\n引擎 范围查询特点 Hash 无序，适合点查，不适合直接做范围查询 SkipList 定位起点后沿底层链表扫描，适合本项目扩展 RBTree 中序遍历有序，也能实现范围查询 B/B+Tree 适合磁盘页和大规模范围扫描 LSM-Tree 适合高写入吞吐的持久化 KV 本项目的课后题应优先使用 SkipList，并在原有 5+2 之外增加 RANGE 接口。\n10.3 频繁 malloc/free 时怎样管理 KV 内存？ 当前项目已经通过 kvs_malloc/kvs_free 留出了统一入口。后续可以逐步演进：\n在包装层统计分配次数、字节数、峰值和泄漏。 对固定大小的节点使用对象池或 slab。 对不同长度的 key/value 使用 size class 和空闲链表。 一次申请大块内存，再切成多个小对象复用。 多线程下使用线程本地缓存或分片内存池，降低锁竞争。 修改 value 时，如果旧缓冲区容量足够就复用；否则先申请新块，成功后再替换。 设置最大内存、淘汰策略并监控碎片率。 内存池解决的是性能和碎片问题，不能替代正确的所有权规则。无论使用系统分配器还是内存池，都必须明确：谁申请、谁持有、谁释放，以及失败时如何回滚。\n11. 本阶段的实施顺序 统一 Hash 返回值与其他引擎的语义 → 修正初始化、失败回滚、MOD 和三条释放路径 → Makefile 加入 kvs_hash.o → 添加 global_hash 的 create/destroy → 协议层增加 HSET/HGET/HDEL/HMOD/HEXIST → 复用上一篇测试状态链验证 Hash → 五种语言运行完全相同的协议用例 → 按相同模板接入 SkipList → 单独设计 RANGE 接口和测试 → 压测后再决定分片、加锁和内存池方案 总结 本阶段相对于前三篇真正新增的主线是：\nHash 实现 → 用统一接口包装 → 进入 Makefile → 进入全局生命周期 → 进入协议分发 → 被同一测试链验证 → 通过统一 TCP 协议服务五种语言 最重要的认识是：\n项目扩展的重点不是孤立地增加一种数据结构，而是把它组织成符合现有接口、生命周期、协议和测试约定的 KV 引擎。\nSkipList 的课后扩展也应遵循同一思路；只有范围查询属于新的业务能力，需要在原有 5+2 之外明确增加接口。\n","date":"2026-08-26T00:00:00+08:00","image":"/MyBlog/p/kv-store-hash-multilingual-clients-skiplist/cover.svg","permalink":"/MyBlog/p/kv-store-hash-multilingual-clients-skiplist/","title":"KV 存储项目：Hash 接入、多语言客户端与 SkipList 扩展"},{"content":" 阅读目标：沿着“建立可重复的测试—用压力测试暴露状态问题—在现有协议后接入第二种 KV 引擎”这条主线，回顾项目从数组存储继续向多引擎结构演进的过程。\n本文承接 KV 存储项目网络层：统一接入 Reactor、Proactor 与协程 和 KV 存储项目：协议层与数组存储。网络层的 Reactor、Proactor、ntyco，以及 kvs_protocol()、token 切分、数组五种操作等内容，前文已经完整讲过，这里只保留理解本阶段所需的连接点。\n本文以学习笔记和当前代码为准，重点解释实现过程、测试现象与架构关系，不展开红黑树算法本身。\n1. 这一阶段解决什么问题 前一阶段已经形成了一条可以工作的请求链路：\n客户端发送文本命令 ↓ 网络层接收数据 ↓ kvs_protocol() 切分 token ↓ kvs_filter_protocol() 识别命令 ↓ 数组引擎执行 SET / GET / DEL / MOD / EXIST ↓ 协议层生成 OK / EXIST / NO EXIST / value ↓ 网络层把响应发回客户端 项目能处理命令以后，下一步不只是继续添加代码，而是先回答三个问题：\n已有功能能否被稳定、重复地验证？ 测试次数增加后，内部状态是否仍然正确？ 协议层能否在数组之外接入新的存储引擎？ 所以本阶段的主线是：\nMakefile 统一构建 ↓ TCP 客户端实现自动化功能测试 ↓ 重复执行测试并粗测 QPS ↓ 通过高次数测试定位数组状态问题 ↓ 保留原有数组引擎，接入红黑树引擎 ↓ 用同一组语义测试第二种引擎 这里体现了一种项目迭代思路：先观察当前代码已经处于什么形态，再决定下一项功能应该接入哪一层。\n2. 用 Makefile 固定构建过程 随着源文件增加，手工重复输入完整的 gcc 命令会越来越不方便。最初可以直接把编译过程写进 all：\nall: gcc -o kvstore kvstore.c reactor.c proactor.c kvs_array.c ntyco.c \\ -I ./NtyCo/core/ -L ./NtyCo/ -luring -lntyco clean: rm -rf kvstore 这条命令由四部分组成：\n部分 当前内容 作用 编译器 gcc 编译并链接 C 程序 target kvstore 最终生成的程序 sources kvstore.c 等 参加编译的源文件 flags -I、-L、-l 指定头文件、库目录和链接库 把这些部分提取成变量后，Makefile 中各项职责更清楚：\nCC=gcc TARGET = kvstore SRCS = kvstore.c reactor.c proactor.c kvs_array.c ntyco.c kvs_rbtree.c INC = -I ./NtyCo/core/ LIBS = -L ./NtyCo/ -luring -lntyco all: $(CC) -o $(TARGET) $(SRCS) $(INC) $(LIBS) clean: rm -rf kvstore 当前阶段加入红黑树后，构建层面的变化很小：在 SRCS 中加入 kvs_rbtree.c。也就是说，存储引擎虽然增加了，但整个项目仍由同一个 target 统一构建。\n2.1 NtyCo 是项目的外部依赖 INC 和 LIBS 都引用了 ./NtyCo/：\nINC → ./NtyCo/core/ 中的头文件 LIBS → ./NtyCo/ 中的 libntyco 因此，Makefile 描述的是“依赖已经存在时怎样编译”。如果本地没有 NtyCo 目录，还要先完成依赖准备，也就是把对应代码下载到项目约定的位置，再进行编译。\n这可以把构建理解成两个阶段：\n准备第三方依赖 ↓ 编译当前项目 Makefile 的价值不只在于少输入一条命令，更重要的是把当前项目由哪些文件、头文件路径和库组成固定下来。\n3. 客户端测试用例：把人工验证变成固定流程 测试客户端的基本流程很直接：\n1. 建立 TCP 连接 2. 发送一条协议命令 3. 接收服务端响应 4. 将实际响应与预期响应比较 5. 一致则 PASS，不一致则 FAILED 测试代码把这五步拆成几个职责单一的函数：\n函数 作用 connect_tcpserver() 创建 socket，并连接指定 IP 和端口 send_msg() 发送一条测试命令 recv_msg() 接收服务端返回值 testcase() 完成一次“发送—接收—比较” array_testcase() 组织数组引擎的一组功能场景 rbtree_testcase() 组织红黑树引擎的一组功能场景 代码中的注释“一个函数只做一件事”在这里得到了体现：网络连接、一次断言和一组业务场景分别由不同函数承担。\n3.1 一个测试用例包含什么 testcase() 接收四项信息：\nvoid testcase(int connfd, char *msg, char *pattern, char *casename) 它们分别表示：\n参数 含义 示例 connfd 已连接的 TCP socket 与 KV 服务端的连接 msg 发给服务端的命令 SET Dad Jasper pattern 期望得到的响应 OK\\r\\n casename 用于输出的测试名称 SET-Dad 测试执行过程可以压缩为：\nmsg ──send──\u0026gt; KV 服务端 │ └── response ──recv──\u0026gt; result │ strcmp(result, pattern) │ │ 相同 不同 │ │ PASS FAILED 因此，测试用例并不需要知道数组或红黑树内部怎样保存数据。它只站在客户端角度检查协议行为，这正好覆盖了完整链路：\nTCP 收发 + 协议解析 + 引擎操作 + 响应生成 4. 一组完整场景不只是“五条命令” 项目一共有五种基础操作：\nSET / GET / DEL / MOD / EXIST 但“覆盖五个命令”不等于每个命令只执行一次。当前 array_testcase() 实际组织了九个连续步骤：\n顺序 请求 预期响应 验证的状态 1 SET Dad Jasper OK 新键可以写入 2 GET Dad Jasper 写入后的值可以读出 3 MOD Dad Sao OK 已存在的键可以修改 4 GET Dad Sao 修改结果已经生效 5 EXIST Dad EXIST 已存在的键能被识别 6 DEL Dad OK 已存在的键可以删除 7 GET Dad NO EXIST 删除后无法再读取 8 MOD Dad Jasper NO EXIST 删除后无法再修改 9 EXIST Dad NO EXIST 删除后状态确实不存在 这九步不是彼此独立的命令，而是一条状态变化链：\n不存在 │ SET ▼ Dad = Jasper │ MOD ▼ Dad = Sao │ DEL ▼ 不存在 测试在每个关键状态后使用 GET 或 EXIST 进行确认。这样不仅验证命令返回了什么，还验证前一条命令是否真正改变了存储状态。\n4.1 测试覆盖的两个方向 当前场景同时覆盖：\n正向结果：写入成功、读取成功、修改成功、存在、删除成功； 反向结果：删除后读取、修改和判断存在均返回 NO EXIST。 “尽量覆盖项目所有结果，目标达到 90% 以上”可以理解为：不仅让每个函数运行一次，还要让函数中的主要结果分支都被执行到。对于协议层而言，要关注的结果包括：\nOK EXIST NO EXIST ERROR 具体 value 测试用例会贯穿后续项目：新增命令、新增引擎或调整内部结构以后，都可以重复运行同一套预期，确认原有行为仍然成立。这就是回归测试的作用。\n5. 从功能测试扩展到压力测试 功能测试回答的是：\n一组操作的结果是否正确？\n压力测试进一步回答：\n同一组操作重复很多次以后，结果是否仍然正确？大约能处理多少请求？\narray_testcase_10w() 将九条命令循环执行：\nint count = 10000; for (i = 0; i \u0026lt; count; i++) { /* 每轮执行 9 条请求 */ } 所以请求总数为：\n10000 轮 × 9 条/轮 = 90000 条请求 计时使用 gettimeofday()：\ntv_begin：循环开始时间 tv_end：循环结束时间 time_used：两者相差的毫秒数 宏 TIME_SUB_MS 把秒和微秒的差值统一换算为毫秒：\n#define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) QPS 的基本计算关系是：\nQPS = 请求总数 ÷ 耗时秒数 = 请求总数 × 1000 ÷ 耗时毫秒数 这里的“请求总数”应按实际循环次数和每轮命令数计算。数组测试中是 90000；红黑树测试同样是 10000 × 9 = 90000。阅读输出时先统一这个口径，两个引擎的数据才可以放在一起比较。\n5.1 三次粗测说明了什么 笔记记录了 9000 次请求附近的三次结果：\n测试环境 耗时 粗测 QPS 客户端输出每次 PASS，服务端也打印 477 ms 18867 屏蔽客户端 PASS 输出 415 ms 21686 继续去掉服务端打印 285 ms 31578 三组数据的重点不是得到一个绝对性能结论，而是观察到：\n测试总耗时 = 网络收发耗时 + 协议处理耗时 + 引擎操作耗时 + 客户端打印耗时 + 服务端打印耗时 当 printf() 位于高频路径中，它也属于被计时的工作。逐步屏蔽打印后，QPS 上升，说明这次粗测测到的是整个端到端过程，而不只是 KV 引擎本身。\n5.2 当前 QPS 的准确含义 当前压力客户端只有一个进程、一条 TCP 连接，并且每次都按下面的顺序执行：\nsend 一条请求 ↓ 等待 recv 一条响应 ↓ 比较结果 ↓ 发送下一条请求 因此当前结果更准确地说是：\n单客户端、单连接、串行请求、包含协议校验的端到端吞吐量。\n它适合观察同一环境下的相对变化，例如开关日志前后的差异，以及数组与红黑树在相同测试场景下的差异。\n6. 为什么小次数正常，高次数却失败 压力测试增加到 90000 次请求后，出现了：\nFAILED-\u0026gt; SET-Dad,ERROR!=OK 从客户端场景看，每轮都执行：\nSET Dad Jasper ... DEL Dad 一轮结束时，Dad 已经删除，所以下一轮的 SET Dad Jasper 应该仍然能够成功。实际数据量始终只在 0 和 1 之间变化：\nSET 后：1 个元素 DEL 后：0 个元素 SET 后：1 个元素 DEL 后：0 个元素 但是数组实例使用 total 记录有效元素数量。如果插入成功执行 total++，删除成功却没有对应地维护数量，就会形成两套不同的状态：\n实际元素数量：0 → 1 → 0 → 1 → 0 ... total： 0 → 1 → 1 → 2 → 2 ... 循环次数少时，total 还没有触及 KVS_ARRAY_SIZE，现象不明显；循环次数持续增加后，最终会到达容量判断：\nif (inst-\u0026gt;total \u0026gt;= KVS_ARRAY_SIZE) 于是后续 SET 返回错误。与此同时，查找范围由 total 决定，total 不断增大也会使遍历范围越来越长，所以压力测试还能观察到性能逐渐变化。\n6.1 total 的核心约定 数组引擎需要维持一个清楚的不变量：\ntable[0] 到 table[total - 1]：有效元素 table[total] 以及后面的位置：空闲空间 total：当前有效元素数量 按照这个约定：\n插入成功 → total++ 删除成功 → total-- 如果删除数组中间的元素，可以用最后一个有效元素补到删除位置，从而继续保持有效区间连续：\n删除前：[Dad, Mom, Son] total = 3 删除 Mom 后出现空位：[Dad, 空, Son] 把最后一个元素移到空位：[Dad, Son, 空] 最终：total = 2 这个问题说明，容量字段不是只在创建时赋值、插入时递增的计数器，而是数据结构状态的一部分。每一种改变元素数量的操作都必须共同维护它。\n7. 压力测试进一步暴露的几个语义问题 高次数运行不仅检查容量，还会不断经过删除、查询不存在和修改不存在等分支，因此能把返回值、内存和状态维护问题一起暴露出来。\n7.1 删除 value 时的内存顺序 下面这个表达式先执行赋值：\nkvs_free(inst-\u0026gt;table[i].value = NULL); 它的实际求值顺序是：\ninst-\u0026gt;table[i].value = NULL ↓ kvs_free(NULL) 原来保存在 value 中的地址因此没有被交给 kvs_free()。正确理解“释放后置空”的顺序是：\n先把原地址交给 kvs_free ↓ 再把保存该地址的指针设为 NULL 也就是：\nkvs_free(inst-\u0026gt;table[i].key); inst-\u0026gt;table[i].key = NULL; kvs_free(inst-\u0026gt;table[i].value); inst-\u0026gt;table[i].value = NULL; 这里要记住的不是某一行写法，而是指针生命周期：地址仍然可达时释放内存，释放完成后再清除悬空指针。\n7.2 循环下标与业务状态不是同一种含义 如果同时存在：\nint i = 0; for (int i = 0; i \u0026lt; inst-\u0026gt;total; i++) { ... } for 中的 i 属于内层作用域，会遮蔽外层的 i。循环结束后，外层 i 仍是初始值 0。\n更关键的是，即使只保留一个 i，return i 仍然混合了两种概念：\ni：遍历到了哪个数组下标 返回值：本次 KV 操作是什么结果 数组为空时尤其容易看清这个区别：\ni = 0 total = 0 循环一次都不执行 return i 得到 0 这里的 0 只是下标初始值，并不能说明修改或删除成功。\n因此，学习时应把业务状态单独记忆：\n返回值 协议层理解 \u0026lt; 0 参数或执行错误，生成 ERROR 0 操作成功，或 EXIST 查询确认存在 \u0026gt; 0 SET 时表示已存在；DEL/MOD/EXIST 时表示不存在 7.3 两种失败现象来自同一套返回值约定 删除成功后如果返回 1，协议层会按照既定规则生成：\nDEL 返回 1 → NO EXIST 于是客户端看到：\nFAILED-\u0026gt; DEL-Dad,NO EXIST!=OK 删除以后再执行 MOD，如果数组为空、循环未执行，最后又通过 return i 返回初始值 0，协议层会生成：\nMOD 返回 0 → OK 于是客户端看到：\nFAILED-\u0026gt; MOD-Dad,OK!=NO EXIST 这两种现象虽然出现在不同命令中，核心都是同一件事：引擎函数的返回值属于内部业务协议，协议层会严格按照该约定翻译成文本响应。\n可以把这条关系记成：\n引擎内部状态 ↓ 固定返回值 ↓ kvs_filter_protocol() ↓ 客户端可见响应 ↓ testcase() 与预期比较 测试失败信息最终帮助定位到的，不一定是 TCP 层，也可能是更深处的状态维护或返回值语义。\n8. 从单一数组引擎扩展为多引擎 前一阶段的协议层只连接数组：\nSET / GET / DEL / MOD / EXIST ↓ global_array 本阶段保留已经成型的数组实现，并通过宏加入红黑树：\n#define ENABLE_ARRAY 1 #define ENABLE_RBTREE 1 这两个开关控制相关类型、接口、全局实例、协议分支、初始化和销毁代码是否参与编译。这样可以在接入期间让两种引擎同时存在，并分别运行测试。\n项目结构由此变成：\nkvs_protocol ↓ kvs_filter_protocol ↓ 根据命令选择对应的引擎 ↙ ↘ SET/GET/DEL/MOD/EXIST RSET/RGET/RDEL/RMOD/REXIST ↓ ↓ global_array global_rbtree 这里没有改变底层网络。无论使用 Reactor、Proactor 还是 ntyco，网络层仍然只调用同一个 kvs_protocol()。增加的是协议层后面的存储选择。\n9. 为什么给红黑树命令增加 R 前缀 一条普通的 SET Dad Jasper 只表达了“设置键值”，没有说明数据应该存进数组还是红黑树。\n当前阶段采用最直接的区分方法：在协议命令中加入引擎标识。\n数组命令 红黑树命令 语义 SET RSET 新增 key-value GET RGET 查询 value DEL RDEL 删除 key-value MOD RMOD 修改 value EXIST REXIST 判断 key 是否存在 命令字符串表因此扩展为十项：\nconst char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34;, \u0026#34;RSET\u0026#34;, \u0026#34;RGET\u0026#34;, \u0026#34;RDEL\u0026#34;, \u0026#34;RMOD\u0026#34;, \u0026#34;REXIST\u0026#34; }; 枚举与字符串表保持相同顺序，kvs_filter_protocol() 找到命令编号后，通过 switch 进入对应分支：\nRSET ↓ KVS_CMD_RSET ↓ kvs_rbtree_set(\u0026amp;global_rbtree, key, value) ↓ 按照返回值生成 OK / EXIST / ERROR 因此，R 前缀在当前阶段承担的是一个简单的路由信息：让协议层知道应该调用哪种引擎。\n10. 每种数据结构都遵守“5 + 2”接口 数组引擎已经形成七个接口：\ncreate destory set get del mod exist 可以压缩为：\n2 个生命周期操作 + 5 个 KV 操作 = 5 + 2 红黑树接入时也提供相同形态：\nint kvs_rbtree_create(kvs_rbtree_t *inst); void kvs_rbtree_destory(kvs_rbtree_t *inst); int kvs_rbtree_set(kvs_rbtree_t *inst, char *key, char *value); char *kvs_rbtree_get(kvs_rbtree_t *inst, char *key); int kvs_rbtree_del(kvs_rbtree_t *inst, char *key); int kvs_rbtree_mod(kvs_rbtree_t *inst, char *key, char *value); int kvs_rbtree_exist(kvs_rbtree_t *inst, char *key); 协议层因此不需要理解旋转、着色、后继节点等红黑树内部细节。它只关心：\n用哪个实例 调用哪个 KV 操作 如何解释返回值 这正是本次“加入红黑树”最值得回顾的部分：不是从零推导红黑树算法，而是把已有数据结构包装成项目已经形成的 KV 引擎接口。\n同样的思路也可以用于后续哈希引擎：哈希表内部怎样定位 key 属于数据结构实现；接入项目时，仍围绕生命周期和五种 KV 语义组织接口。\n11. 红黑树在项目中的最小理解范围 为了理解本项目中的接入，只需要掌握下面几层关系。\n11.1 树实例和节点 红黑树实例保存：\ntypedef struct _rbtree { rbtree_node *root; rbtree_node *nil; } rbtree; 其中：\nroot 指向根节点； nil 是哨兵节点，用于表示空孩子和空树边界； nil 初始化为黑色； 创建完成时，root 指向 nil，表示树中还没有 KV 节点。 节点中与 KV 项目直接相关的是：\nkey → 用于比较和查找 value → 与 key 对应的数据 父节点、左右孩子和颜色则服务于红黑树结构本身。\n11.2 五种 KV 操作如何映射到底层树操作 KV 接口 在红黑树中的主要过程 kvs_rbtree_set() 为 key/value 分配空间，构造节点并插入树 kvs_rbtree_get() 按 key 搜索节点，返回节点的 value kvs_rbtree_del() 搜索节点，删除节点并释放相关空间 kvs_rbtree_mod() 搜索节点，替换已有 value kvs_rbtree_exist() 搜索节点，通过是否到达 nil 判断存在性 这些包装函数把“树节点操作”转换成与数组引擎一致的“KV 操作”。\n11.3 生命周期怎样进入程序主流程 程序启动时，两种引擎分别初始化：\ninit_kvengine() ├─ kvs_array_create(\u0026amp;global_array) └─ kvs_rbtree_create(\u0026amp;global_rbtree) 网络服务结束后，再进入统一销毁阶段：\ndest_kvengine() ├─ kvs_array_destory(\u0026amp;global_array) └─ kvs_rbtree_destory(\u0026amp;global_rbtree) 红黑树销毁时需要逐步删除树中的节点，最后处理树实例的哨兵节点。具体采用最小节点、最大节点或其他顺序，是树内部的销毁策略；对项目主线而言，重要的是 destory 与 create 共同构成完整生命周期。\n12. 用同一套语义验证红黑树引擎 rbtree_testcase() 与 array_testcase() 的业务顺序保持一致，只是命令增加了 R 前缀：\n数组： SET → GET → MOD → GET → EXIST → DEL → GET → MOD → EXIST 红黑树：RSET → RGET → RMOD → RGET → REXIST → RDEL → RGET → RMOD → REXIST 这使测试具有两个层次：\n单独验证每个引擎是否满足五种 KV 操作； 验证两种引擎在相同业务场景下是否表现出相同语义。 可以把两组用例看成一份“行为契约”：\n初始状态和操作 数组响应 红黑树响应 不存在时 SET OK OK SET 后 GET 对应 value 对应 value 已存在时 MOD OK OK 已存在时 EXIST EXIST EXIST 已存在时 DEL OK OK DEL 后 GET/MOD/EXIST NO EXIST NO EXIST 只要对外行为一致，客户端就不需要了解底层究竟使用数组还是红黑树。当前协议用命令前缀显式选择引擎，但测试验证的是二者共同的 KV 语义。\n13. 数组与红黑树在当前项目中的角色 本阶段不是简单地用红黑树替换数组，而是让两种实现同时存在：\n维度 数组引擎 红黑树引擎 协议命令 五种普通命令 五种 R 前缀命令 全局实例 global_array global_rbtree 生命周期 create/destory create/destory KV 接口 set/get/del/mod/exist set/get/del/mod/exist 内部组织 连续数组区间 根节点、哨兵和树节点 查找方式 在有效区间中遍历 按 key 在树中搜索 真正稳定下来的不是某一种数据结构，而是数据结构外面的项目边界：\n协议命令 ↓ 统一的 KV 语义 ↓ 不同引擎各自实现 这也解释了“要什么需求、用什么技术方案实现”的关系：\n需求是保存、查询、删除、修改和判断 key； 数组和红黑树是两种实现这些需求的技术方案； 协议层负责把客户端命令路由给具体方案； 测试用例负责确认不同方案满足相同的外部预期。 14. 关于多进程、多线程压力测试的课后思考 当前单连接测试中，每条请求都要等待上一条响应，无法同时给服务端制造多个在途请求。要观察并发场景，可以让多个执行单元各自建立连接并运行同一组测试。\n14.1 多进程思路 父进程 ├─ fork → 子进程 1 → 建立连接 → 重复测试 ├─ fork → 子进程 2 → 建立连接 → 重复测试 └─ fork → 子进程 N → 建立连接 → 重复测试 每个子进程拥有自己的客户端 socket 和测试循环。父进程等待所有子进程结束，再汇总：\n总请求数 = 进程数 × 每个进程的请求数 整体 QPS = 总请求数 × 1000 ÷ 整体耗时毫秒数 14.2 多线程思路 一个客户端进程 ├─ 线程 1 → 独立连接 → 重复测试 ├─ 线程 2 → 独立连接 → 重复测试 └─ 线程 N → 独立连接 → 重复测试 每个线程使用独立连接时，测试流程和当前代码最接近；主线程统一记录开始与结束时间，并统计所有线程完成的请求总数。\n14.3 并发数增加后观察什么 并发测试不只是期待 QPS 一定上升，还要同时观察：\n所有响应是否仍然符合预期； 总吞吐量怎样随客户端数量变化； 单次请求延迟是否增加； 服务端采用的网络模型是否能发挥并发处理能力； 打印、网络、协议或存储引擎中的哪一部分先成为主要耗时。 因此，多进程或多线程的意义是改变负载模型：从“一个客户端串行发请求”变成“多个客户端同时发请求”。\n15. 这一阶段形成的完整项目画面 把前三个阶段放在一起，可以得到当前项目结构：\n测试客户端 ├─ 功能用例：检查每一步响应 └─ 压力用例：重复场景、计时、计算 QPS │ ▼ TCP 网络 │ ▼ 网络层：Reactor / Proactor / ntyco │ ▼ kvs_protocol()：切分 token │ ▼ kvs_filter_protocol()：识别命令并选择引擎 ┌────┴────┐ ▼ ▼ 数组引擎 红黑树引擎 5 + 2 接口 5 + 2 接口 │ │ └────┬────┘ ▼ 统一返回值与文本响应 Makefile 则从构建角度把这些模块放到一起：\nkvstore.c reactor.c / proactor.c / ntyco.c kvs_array.c kvs_rbtree.c NtyCo 与 liburing ↓ kvstore 16. 最后的复习提纲 16.1 测试主线 connect → send command → recv response → compare pattern → PASS / FAILED 16.2 压力测试主线 固定业务场景 → 重复执行 → 统计实际请求数 → 记录总耗时 → 计算 QPS → 从失败轮次和响应反查内部状态 16.3 数组问题定位主线 少量测试正常、增加次数后 SET 返回 ERROR → 检查容量条件 → 检查 total 的增减 → 发现实际元素已删除但 total 仍增长 → 恢复“有效元素数量”的统一约定 同时记住：\n释放内存：先 free 原地址，再把指针置 NULL 循环下标：只表示位置 函数返回值：表示业务结果 协议响应：由业务返回值翻译得到 16.4 多引擎扩展主线 保留数组成型代码 → 用 ENABLE_ARRAY / ENABLE_RBTREE 控制编译 → 为红黑树提供同样的 5 + 2 接口 → 增加 RSET/RGET/RDEL/RMOD/REXIST → 协议层按命令选择实例 → 用同一套业务场景验证两种引擎 16.5 本阶段最重要的认识 测试用例不是项目完成后的附属代码，而是项目继续迭代时用来确认行为的固定标准。\n小次数测试验证基本功能，高次数测试还会验证状态能否长期保持一致。\ntotal、节点数量和指针生命周期都属于数据结构必须维护的内部状态。\n数组、红黑树以及后续哈希表可以采用不同内部实现，但都可以通过相同的 KV 语义接入协议层。\n本阶段加入红黑树的重点，是把一种已有数据结构组织成项目需要的存储引擎，而不是从零实现红黑树算法。\n","date":"2026-08-25T00:00:00+08:00","image":"/MyBlog/p/kv-store-testing-multi-engine/cover.svg","permalink":"/MyBlog/p/kv-store-testing-multi-engine/","title":"KV 存储项目：客户端测试、压力测试与多引擎扩展"},{"content":" 阅读目标：沿着“收到一条命令—解析命令—操作数组—生成响应”这条主线，回顾 KV 项目从 echo 协议发展到实际命令处理的过程。\n本文承接 KV 存储项目网络层：统一接入 Reactor、Proactor 与协程。上一篇已经讲过 Reactor、Proactor、ntyco、msg_handler 和网络收发流程，本文不再重复网络层实现，只说明它怎样把请求交给协议层。\n本文以笔记和现有代码为准，解释代码在项目中的作用与执行过程，不讨论修改方案。\n1. 从网络层进入协议层 上一篇已经完成了网络层。无论底层选择 Reactor、Proactor 还是 ntyco，收到请求后都会调用同一个入口：\nkvs_protocol(msg, length, response); 这一篇从这里继续：\n网络层收到客户端数据 ↓ kvs_protocol() 进入协议层 ↓ kvs_split_token() 切分命令 ↓ kvs_filter_protocol() 识别命令 ↓ kvs_array_xxx() 操作 KV 数据 ↓ 把执行结果写入 response ↓ 网络层发送响应 网络层解决“数据怎样到达”，协议层解决“收到的数据表示什么、应该返回什么”。\n2. 为什么 TCP 之上还需要业务协议 假设浏览器请求一个包含数据的网页，业务链路可以是：\n浏览器 → NodeServer → KV 存储服务器 NodeServer 与 KV 存储服务器建立 TCP 连接后，还要约定双方发送数据的格式。例如：\nSEND: SET Key Value\\r\\n RECV: OK\\r\\n SEND: GET Key\\r\\n RECV: King\\r\\n TCP 是公共的传输协议，只负责按顺序传输字节；SET 和 GET 的含义属于项目自己定义的应用层协议，也可以称为业务协议或私有协议。\n本项目采用容易观察的文本命令：\nSET Key Value GET Key DEL Key MOD Key Value EXIST Key 其中空格用于分隔命令和参数，响应通过 response 缓冲区交还给网络层。\n3. TCP 消息边界：理解协议的前提 TCP 是字节流，一次 send() 不一定对应一次 recv()。\n客户端连续发送：\nSET Key Value\\r\\n SET Key1 Value1\\r\\n SET Key2 Value2\\r\\n 接收方可能一次 recv() 收到多条命令，也可能只收到一条命令的一部分。因此应用层需要自己识别完整消息的边界。\n笔记中记录了两种约定边界的思路。\n3.1 在包头记录长度 在数据最前面使用固定字节表示后续消息长度：\nshort length = 0; recv(fd, \u0026amp;length, 2, 0); recv(fd, buffer, length, 0); 概念流程是：\n先读取固定长度的包头 ↓ 得到消息体长度 length ↓ 继续读取 length 个字节 ↓ 取得一条完整消息 3.2 使用结束标记 文本协议也可以约定用 \\r\\n 表示一行结束，再逐行解析命令。\n无论采用长度字段还是结束标记，目的都是让接收方能够从连续的 TCP 字节流中还原出一条条完整命令。\nET、LT 对应不同的事件触发方式；它们都能处理多个数据包，具体读取过程属于上一篇网络层的延伸问题。本文继续关注完整命令到达 kvs_protocol() 之后的处理。\n4. Redis 协议带来的解析思路 SET Key Value 可以拆成三个 token：\nSET Key Value 笔记中的 Redis 协议简化示意不仅传输 token 内容，还记录 token 数量和每个 token 的长度：\n3\\r\\n 3\\r\\n SET\\r\\n 3\\r\\n Key\\r\\n 5\\r\\n Value\\r\\n 可以依次理解为：\n共有 3 个 token SET 的长度是 3 Key 的长度是 3 Value 的长度是 5 GET Teacher 则有两个 token：\n2\\r\\n 3\\r\\n GET\\r\\n 7\\r\\n Teacher\\r\\n 对应的读取伪代码是：\nreadline(fd, buffer); count = atoi(buffer); for (int i = 0; i \u0026lt; count; i++) { readline(fd, tokenlen); readline(fd, token); } 解析主线是：\n读取 token 数量 ↓ 循环读取每个 token 的长度 ↓ 按照长度读取 token 内容 ↓ 组合成一条完整命令 当前项目代码采用更直接的形式：收到 SET Key Value 后，以空格调用 strtok() 进行切分。\n5. 文件职责 本次代码可以按职责分为三部分：\nkvstore.h ├─ 公共宏与函数声明 ├─ 数组 KV 数据结构 └─ CRUD 接口 kvstore.c：协议部分 ├─ 内存接口封装 ├─ token 切分 ├─ 命令识别与响应生成 ├─ KV 引擎初始化 └─ main 数组存储部分 ├─ global_array ├─ create / destory └─ set / get / del / mod / exist 上一篇的 kvs_protocol() 只是把请求复制到响应中；本次代码让它开始解析并执行真实的 KV 命令。\n6. kvstore.h：数组引擎的数据结构与接口 6.1 编译配置 #define KVS_MAX_TOKENS 128 #define ENABLE_ARRAY 1 #define KVS_ARRAY_SIZE 1024 宏 代码中的含义 KVS_MAX_TOKENS tokens 数组容量 ENABLE_ARRAY 是否编译数组 KV 引擎 KVS_ARRAY_SIZE KV 数组的容量 6.2 一个 KV 元素 typedef struct kvs_array_item_s { char *key; char *value; } kvs_array_item_t; 数组中的每一个元素保存一对指针：\ntable[i] ├─ key → 键字符串 └─ value → 值字符串 6.3 整个数组实例 typedef struct kvs_array_s { kvs_array_item_t *table; int idx; int total; } kvs_array_t; 字段 含义 table 指向 KV 元素数组 idx 数组实例中的索引字段 total 当前记录的元素数量 数组引擎向协议层提供以下操作：\nint kvs_array_create(kvs_array_t *inst); void kvs_array_destory(kvs_array_t *inst); int kvs_array_set(kvs_array_t *inst, char *key, char *value); char *kvs_array_get(kvs_array_t *inst, char *key); int kvs_array_del(kvs_array_t *inst, char *key); int kvs_array_mod(kvs_array_t *inst, char *key, char *value); int kvs_array_exist(kvs_array_t *inst, char *key); 7. 封装内存分配接口 代码没有让各个数据操作直接依赖 malloc() 和 free()，而是包了一层：\nvoid *kvs_malloc(size_t size) { return malloc(size); } void kvs_free(void *ptr) { free(ptr); } 其他模块统一使用：\nkvs_malloc(size); kvs_free(ptr); 笔记中的目的，是给内存管理保留统一入口。后续如果跨平台、引入内存池或定制内存分配，只需要从这一层接入，不必改变所有调用位置。\n这体现了项目迭代中的一个思路：先通过统一接口隔开业务代码与底层实现。\n8. 创建数组实例：kvs_array_create() 全局数组实例定义为：\nkvs_array_t global_array = {0}; 创建函数：\nint kvs_array_create(kvs_array_t *inst) { if (inst == NULL) return -1; if (inst-\u0026gt;table) { printf(\u0026#34;table has been alloced\u0026#34;); return -1; } inst-\u0026gt;table = kvs_malloc( KVS_ARRAY_SIZE * sizeof(kvs_array_item_t) ); if (!inst-\u0026gt;table) return -1; inst-\u0026gt;total = 0; return 0; } 执行过程：\n检查 inst ↓ 检查 table 是否已经指向数组 ↓ 申请 KVS_ARRAY_SIZE 个元素的空间 ↓ total 设为 0 程序启动时，init_kvengine() 负责初始化它：\nint init_kvengine(void) { #if ENABLE_ARRAY memset(\u0026amp;global_array, 0, sizeof(kvs_array_t)); kvs_array_create(\u0026amp;global_array); #endif return 0; } 9. SET：保存一组 key-value 接口：\nint kvs_array_set(kvs_array_t *inst, char *key, char *value); 主要执行顺序：\n检查 inst、key、value ↓ 检查 total 是否达到数组容量 ↓ 调用 kvs_array_get() 查找 key ↓ key 已存在时返回 1 ↓ 分别为 key 和 value 分配内存 ↓ 复制 key 和 value ↓ 寻找存放位置并写入 table ↓ total 加 1，返回 0 代码为字符串申请 strlen(...) + 1 字节：\nchar *kcopy = kvs_malloc(strlen(key) + 1); char *kvalue = kvs_malloc(strlen(value) + 1); 多出的一个字节用于字符串结尾的 \\0。\n返回值在当前协议中的含义：\nret \u0026lt; 0 → 执行错误 ret == 0 → 设置成功 ret \u0026gt; 0 → key 已存在 10. GET：按照 key 查找 value 接口：\nchar *kvs_array_get(kvs_array_t *inst, char *key); 查询过程：\n检查 inst 和 key ↓ 从 table[0] 开始遍历 ↓ 跳过 key 为空的元素 ↓ strcmp() 比较元素 key 和目标 key ↓ 找到后返回 value ↓ 遍历结束仍未找到则返回 NULL 核心比较：\nif (strcmp(inst-\u0026gt;table[i].key, key) == 0) { return inst-\u0026gt;table[i].value; } 因此 kvs_array_get() 的结果可以直接被 SET、GET 和 EXIST 复用。\n11. DEL：删除 key-value 接口：\nint kvs_array_del(kvs_array_t *inst, char *key); 执行主线：\n检查 inst 和 key ↓ 遍历 table ↓ strcmp() 找到相同 key ↓ 释放 key 对应的内存 ↓ 释放 value 对应的内存 ↓ 返回执行结果 协议层按照约定把返回值转换为：\nERROR / OK / NO EXIST 12. MOD：修改已有 key 的 value 接口：\nint kvs_array_mod(kvs_array_t *inst, char *key, char *value); 执行主线：\n检查 inst、key、value ↓ 遍历 table 查找 key ↓ 释放原来的 value ↓ 按照新字符串长度申请内存 ↓ 复制新的 value ↓ 把新 value 写回当前元素 MOD 保留原来的 key，只更新与它关联的 value。\n13. EXIST：复用 GET 判断 key 是否存在 int kvs_array_exist(kvs_array_t *inst, char *key) { char *str = kvs_array_get(inst, key); if (!str) { return 1; } return 0; } 返回值含义：\n0 → key 存在 1 → key 不存在 这里没有再次编写数组遍历，而是调用 kvs_array_get() 完成查找。\n14. 命令字符串与枚举的对应关系 命令字符串表：\nconst char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34; }; 枚举：\nenum { KVS_CMD_START = 0, KVS_CMD_SET = KVS_CMD_START, KVS_CMD_GET, KVS_CMD_DEL, KVS_CMD_MOD, KVS_CMD_EXIST, KVS_CMD_COUNT, }; 二者按下标形成对应关系：\ncmd command[cmd] 枚举 0 SET KVS_CMD_SET 1 GET KVS_CMD_GET 2 DEL KVS_CMD_DEL 3 MOD KVS_CMD_MOD 4 EXIST KVS_CMD_EXIST 协议解析通过同一个 cmd 同时连接字符串表与 switch 分支，所以代码注释强调二者顺序保持对应。\n15. kvs_split_token()：把命令切成参数 函数入口：\nint kvs_split_token(char *msg, char *tokens[]) 首先检查参数：\nif (msg == NULL || tokens == NULL) return -1; 然后以空格为分隔符调用 strtok()：\nint idx = 0; char *token = strtok(msg, \u0026#34; \u0026#34;); while (token != NULL) { tokens[idx++] = token; token = strtok(NULL, \u0026#34; \u0026#34;); } return idx; 对于：\nSET Key Value 切分结果是：\ntokens[0] → \u0026#34;SET\u0026#34; tokens[1] → \u0026#34;Key\u0026#34; tokens[2] → \u0026#34;Value\u0026#34; 返回 count = 3 对于：\nGET Key 切分结果是：\ntokens[0] → \u0026#34;GET\u0026#34; tokens[1] → \u0026#34;Key\u0026#34; 返回 count = 2 笔记中强调：函数进入时先进行参数判断。完整项目还会逐步形成统一的参数错误、内存错误和协议错误等返回值约定。\n16. kvs_filter_protocol()：识别命令并生成响应 函数入口：\nint kvs_filter_protocol(char **tokens, int count, char *response); 16.1 查找命令编号 int cmd = KVS_CMD_START; for (cmd = KVS_CMD_START; cmd \u0026lt; KVS_CMD_COUNT; cmd++) { if (strcmp(tokens[0], command[cmd]) == 0) { break; } } 例如 tokens[0] 是 GET，循环最终得到：\ncmd = KVS_CMD_GET 随后统一取出参数位置：\nchar *key = tokens[1]; char *value = tokens[2]; 16.2 SET 分支 ret = kvs_array_set(\u0026amp;global_array, key, value); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 EXIST\\r\\n 16.3 GET 分支 char *result = kvs_array_get(\u0026amp;global_array, key); 查询结果 协议响应 result == NULL NO EXIST\\r\\n 找到 value value\\r\\n 16.4 DEL 分支 ret = kvs_array_del(\u0026amp;global_array, key); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 NO EXIST\\r\\n 16.5 MOD 分支 ret = kvs_array_mod(\u0026amp;global_array, key, value); 数组层结果 协议响应 ret \u0026lt; 0 ERROR\\r\\n ret == 0 OK\\r\\n ret \u0026gt; 0 NO EXIST\\r\\n 16.6 EXIST 分支 ret = kvs_array_exist(\u0026amp;global_array, key); 数组层结果 协议响应 ret == 0 EXIST ret != 0 NO EXIST\\r\\n kvs_filter_protocol() 的角色可以概括为：把文本命令转换成数组函数调用，再把数组函数的返回值转换成文本响应。\n17. kvs_protocol()：本篇代码的总入口 int kvs_protocol(char *msg, int length, char *response) { if (msg == NULL || length \u0026lt;= 0 || response == NULL) return -1; printf(\u0026#34;recv: %d: %s\\n\u0026#34;, length, msg); char *tokens[KVS_MAX_TOKENS] = {0}; int count = kvs_split_token(msg, tokens); if (count == -1) return -1; return kvs_filter_protocol(tokens, count, response); } 三个参数分别来自网络层：\n参数 含义 msg 收到的请求内容 length 请求长度 response 协议层写入响应的缓冲区 执行主线：\n检查参数 ↓ 创建 tokens 数组 ↓ kvs_split_token(msg, tokens) ↓ 得到 token 数量 count ↓ kvs_filter_protocol(tokens, count, response) ↓ 执行命令并形成响应 它把上一篇的“网络回调入口”与本篇的“命令解析和数组 CRUD”连接起来。\n18. 程序启动与数组引擎接入 main() 先取得端口，再初始化 KV 引擎：\nint port = atoi(argv[1]); init_kvengine(); 之后按照 NETWORK_SELECT 启动对应网络层，并继续把 kvs_protocol 作为回调传入：\nreactor_start(port, kvs_protocol); /* 或 */ ntyco_start(port, kvs_protocol); /* 或 */ proactor_start(port, kvs_protocol); 这里不再展开三种网络模型。只需记住：网络模型可以切换，协议入口和数组引擎的调用主线保持不变。\n19. 一条 SET 命令的完整路径 客户端发送：\nSET name King 代码中的执行过程：\n网络层取得 msg ↓ kvs_protocol(msg, length, response) ↓ kvs_split_token() ↓ tokens[0] = \u0026#34;SET\u0026#34; tokens[1] = \u0026#34;name\u0026#34; tokens[2] = \u0026#34;King\u0026#34; ↓ kvs_filter_protocol() ↓ 匹配 KVS_CMD_SET ↓ kvs_array_set(\u0026amp;global_array, \u0026#34;name\u0026#34;, \u0026#34;King\u0026#34;) ↓ 为 key 和 value 分配内存并写入 table ↓ response = \u0026#34;OK\\r\\n\u0026#34; ↓ 网络层发送响应 20. 一条 GET 命令的完整路径 客户端发送：\nGET name 执行过程：\nkvs_protocol() ↓ 切分出 \u0026#34;GET\u0026#34; 和 \u0026#34;name\u0026#34; ↓ kvs_filter_protocol() ↓ 匹配 KVS_CMD_GET ↓ kvs_array_get(\u0026amp;global_array, \u0026#34;name\u0026#34;) ↓ 遍历 table 并比较 key ↓ 找到 value = \u0026#34;King\u0026#34; ↓ response = \u0026#34;King\\r\\n\u0026#34; 如果没有找到，则响应：\nNO EXIST\\r\\n 21. 返回值与协议响应是两个层次 数组函数首先返回内部执行结果，例如：\n小于 0：错误 等于 0：成功或存在 大于 0：已存在或不存在 协议层再将其转换成客户端能够理解的文本：\nOK ERROR EXIST NO EXIST 具体的 value 这与 HTTP 使用 200、404 等状态表达处理结果的思路相似：一个完整项目会逐步形成统一的参数错误、内存分配失败、协议解析失败等返回值标准。\n22. 从功能实现到项目架构 笔记给出的迭代顺序是：\n先保证功能 ↓ 保证协议统一 ↓ 保证参数统一 ↓ 保证返回值统一 数组法先直接实现了 KV 的基本操作：\nSET → 增加 key-value GET → 查询 value DEL → 删除 key-value MOD → 修改 value EXIST → 判断 key 是否存在 随着代码不断迭代，公共接口、模块边界和返回值规则逐渐稳定，进而形成项目架构。\n可以记住两句话：\n架构是在软件不断迭代的过程中逐渐产生的。\n软件代码可以通过分层设计，将网络收发、协议处理和数据存储分开。\n23. 最后的整体记忆 这一阶段最重要的不是分别记住所有函数，而是记住数据在各层之间怎样移动：\n客户端文本命令 ↓ 网络层交给 kvs_protocol ↓ kvs_split_token 拆出 command / key / value ↓ kvs_filter_protocol 匹配命令 ↓ kvs_array_set/get/del/mod/exist ↓ global_array 中的 key-value 数据 ↓ 协议层生成 OK、ERROR、value 等响应 ↓ 网络层发送给客户端 按函数压缩为：\nkvs_protocol → kvs_split_token → kvs_filter_protocol → kvs_array_xxx → response ","date":"2026-08-24T00:00:00+08:00","image":"/MyBlog/p/kv-store-protocol-array/cover.svg","permalink":"/MyBlog/p/kv-store-protocol-array/","title":"KV 存储项目：协议层与数组存储"},{"content":" 阅读目标：先恢复项目的整体认识，再通过带注释的代码块回忆具体实现。\n本文只解析现有成品代码，不讨论修改方案。ntyco 只讲如何作为 KV 项目的网络层使用。\n1. 先恢复项目的整体画面 这个项目的核心不是分别写了三个独立服务器，而是让三个网络服务器共同使用同一个 KV 协议入口：\nkvstore.c main + kvs_protocol │ msg_handler 回调 │ ┌──────────────┼──────────────┐ │ │ │ reactor.c ntyco.c proactor.c epoll 协程 io_uring │ │ │ └──────────────┼──────────────┘ │ TCP 三种网络层只负责连接和收发，kvs_protocol() 负责处理消息：\n客户端请求 ↓ 网络层收到 msg 和 length ↓ kvs_protocol(msg, length, response) ↓ 返回 response 的长度 ↓ 网络层发送 response 项目目前的 kvs_protocol() 是 echo 协议：把请求复制成响应。这一实现展示的重点，是三种网络模型如何接入同一个 KV 协议回调。\n六份文件分成三组：\n公共入口与接口 ├─ kvstore.c └─ kvstore.h Reactor 公共连接定义 └─ server.h 三种网络层 ├─ reactor.c ├─ proactor.c └─ ntyco.c 2. 公共入口：kvstore.h 与 kvstore.c 2.1 kvstore.h：把三种网络层统一成一种调用方式 #ifndef KV_STORE_H #define KV_STORE_H /* 三种网络实现的编号，供 NETWORK_SELECT 选择 */ #define NETWORK_REACTOR 0 #define NETWORK_PROACTOR 1 #define NET_WORK_NTYCO 2 /* 当前编译使用 io_uring Proactor 网络层 */ #define NETWORK_SELECT NETWORK_PROACTOR /* * KV 协议回调类型： * msg 网络层收到的请求 * length 请求的实际长度 * response 协议层写入响应的位置 * 返回值 网络层需要发送的响应长度 */ typedef int (*msg_handler)(char *msg, int length, char *response); /* 三种网络层向 kvstore.c 暴露相同形式的启动函数 */ extern int reactor_start(unsigned short port, msg_handler handler); extern int ntyco_start(unsigned short port, msg_handler handler); extern int proactor_start(unsigned short port, msg_handler handler); /* KV 项目支持的命令名称 */ const char *command[] = { \u0026#34;SET\u0026#34;, \u0026#34;GET\u0026#34;, \u0026#34;DEL\u0026#34;, \u0026#34;MOD\u0026#34;, \u0026#34;EXIST\u0026#34; }; /* 响应字符串表，目前未填充 */ const char *response[] = { }; #endif 这一文件的整体作用可以概括为：\nNETWORK_SELECT 决定使用谁 msg_handler 规定协议函数长什么样 三个 start 规定网络层怎样启动 command 列出 KV 命令集合 2.2 kvstore.c：选择网络层，并把协议函数交给它 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;string.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026#34;kvstore.h\u0026#34; /* * 当前是 echo 形式的 KV 协议入口。 * 网络层把请求传进来，本函数把请求复制成响应。 */ int kvs_protocol(char *msg, int length, char *response) { /* 输出本次请求长度与内容 */ printf(\u0026#34;recv: %d: %s\\n\u0026#34;, length, msg); /* 将收到的 length 个字节复制到响应缓冲区 */ memcpy(response, msg, length); /* 把响应长度交还给网络层 */ return strlen(response); } int main(int argc, char *argv[]) { /* 启动形式要求为：./kvstore \u0026lt;port\u0026gt; */ if (argc != 2) return -1; /* 将命令行中的端口字符串转换成整数 */ int port = atoi(argv[1]); /* * 编译期选择网络模型。 * 三个分支都把同一个 kvs_protocol 作为 handler 传入。 */ #if (NETWORK_SELECT == NETWORK_REACTOR) reactor_start(port, kvs_protocol); #elif (NETWORK_SELECT == NETWORK_NTYCO) ntyco_start(port, kvs_protocol); #elif (NETWORK_SELECT == NETWORK_PROACTOR) proactor_start(port, kvs_protocol); #endif } 这里最需要记住的不是条件编译本身，而是下面三个调用具有完全相同的含义：\nreactor_start(port, kvs_protocol); ntyco_start(port, kvs_protocol); proactor_start(port, kvs_protocol); 可以统一读作：\n在指定端口启动某个网络后端；以后收到消息时，请调用 kvs_protocol()。\n3. Reactor 的连接结构：server.h Reactor 是事件驱动的。一次读事件和下一次写事件不是在同一个函数中连续完成，因此需要 struct conn 保存跨事件存在的连接信息。\n#ifndef SERVER_H #define SERVER_H /* 每个连接的读、写缓冲区都是 1024 字节 */ #define BUFFER_LENGTH 1024 /* 当前 Reactor 上层启用 KVSTORE 协议 */ #define ENABLE_HTTP 0 #define ENABLE_WEBSOCKET 0 #define ENABLE_KVSTORE 1 /* epoll 事件回调：传入发生事件的 fd */ typedef int (*RCALLBACK)(int fd); struct conn { int fd; // 当前连接的 socket fd char rbuffer[BUFFER_LENGTH]; // recv 接收到的请求 int rlength; // 请求的实际长度 char wbuffer[BUFFER_LENGTH]; // KV 协议生成的响应 int wlength; // 响应的实际长度 RCALLBACK send_callback; // EPOLLOUT 时调用 send_cb union { RCALLBACK recv_callback; // 客户端 fd 可读时调用 recv_cb RCALLBACK accept_callback; // 监听 fd 可读时调用 accept_cb } r_action; int status; // 连接/协议状态字段 #if 1 // websocket char *payload; // WebSocket 数据字段 char mask[4]; // WebSocket 掩码字段 #endif }; /* 同一套 Reactor 曾为不同上层协议保留入口 */ #if ENABLE_HTTP int http_request(struct conn *c); int http_response(struct conn *c); #endif #if ENABLE_WEBSOCKET int ws_request(struct conn *c); int ws_response(struct conn *c); #endif #if ENABLE_KVSTORE int kvs_request(struct conn *c); int kvs_response(struct conn *c); #endif #endif struct conn 可以用一条数据流来理解：\nrecv(fd) ↓ rbuffer + rlength ↓ kvs_handler wbuffer + wlength ↓ send(fd) 两个函数指针则决定 epoll 返回事件时执行什么：\n监听 fd 的 EPOLLIN → accept_cb 连接 fd的 EPOLLIN → recv_cb 连接 fd的 EPOLLOUT → send_cb 4. Reactor 网络层：reactor.c 4.1 Reactor 总体流程 创建 epoll ↓ 创建 20 个监听 socket，并注册 EPOLLIN ↓ epoll_wait ├─ 监听 fd 可读 → accept_cb ├─ 客户端 fd 可读 → recv_cb → kvs_protocol └─ 客户端 fd 可写 → send_cb 4.2 KV 回调和连接表 #define CONNECTION_SIZE 1048576 // 连接表容量 #define MAX_PORTS 20 // 连续监听 20 个端口 /* 计算两次 timeval 之间的毫秒差，用于 accept 统计 */ #define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) #if ENABLE_KVSTORE /* reactor_start 会把 kvs_protocol 保存到这里 */ static msg_handler kvs_handler; int kvs_request(struct conn *c) { /* * 将连接的接收缓冲区交给 KV 层； * KV 层把响应写入同一连接的发送缓冲区； * 返回值记录到 wlength。 */ c-\u0026gt;wlength = kvs_handler( c-\u0026gt;rbuffer, c-\u0026gt;rlength, c-\u0026gt;wbuffer ); } int kvs_response(struct conn *c) { /* 当前 KV 响应不再进行二次处理 */ } #endif int epfd = 0; // epoll 实例 fd struct timeval begin; // accept 统计起点 struct conn conn_list[CONNECTION_SIZE]; // 用 fd 直接索引连接 4.3 添加和切换 epoll 事件 int set_event(int fd, int event, int flag) { struct epoll_event ev; ev.events = event; // EPOLLIN 或 EPOLLOUT ev.data.fd = fd; // epoll 返回事件时带回这个 fd if (flag) { /* 新 fd 第一次进入 epoll */ epoll_ctl(epfd, EPOLL_CTL_ADD, fd, \u0026amp;ev); } else { /* 已登记 fd 在读、写关注之间切换 */ epoll_ctl(epfd, EPOLL_CTL_MOD, fd, \u0026amp;ev); } } 这份 Reactor 的主要节奏，就是不断切换同一个客户端的事件：\n刚建立连接 → EPOLLIN 收到并处理请求 → EPOLLOUT 发送完响应 → EPOLLIN 4.4 初始化一个客户端连接 int event_register(int fd, int event) { if (fd \u0026lt; 0) return -1; /* fd 同时也是 conn_list 的下标 */ conn_list[fd].fd = fd; /* 客户端可读和可写时对应的处理函数 */ conn_list[fd].r_action.recv_callback = recv_cb; conn_list[fd].send_callback = send_cb; /* 初始化这个连接的输入区域 */ memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH); conn_list[fd].rlength = 0; /* 初始化这个连接的输出区域 */ memset(conn_list[fd].wbuffer, 0, BUFFER_LENGTH); conn_list[fd].wlength = 0; /* 新连接加入 epoll，调用处传入的是 EPOLLIN */ set_event(fd, event, 1); } 4.5 创建监听 socket int r_init_server(unsigned short port) { /* 创建 IPv4 TCP socket */ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 servaddr.sin_port = htons(port); // 监听端口 /* 把 socket 绑定到本机端口 */ if (bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)) == -1) { printf(\u0026#34;bind failed: %s\\n\u0026#34;, strerror(errno)); } /* 将 socket 变为监听 socket */ listen(sockfd, 10); return sockfd; } 4.6 接受客户端 int accept_cb(int fd) { struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); /* * fd 是监听 socket； * clientfd 是新建立的客户端连接。 */ int clientfd = accept( fd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len ); printf(\u0026#34;accept finshed: %d,fd:%d\\n\u0026#34;, clientfd, fd); if (clientfd \u0026lt; 0) { printf(\u0026#34;accept errno: %d --\u0026gt; %s\\n\u0026#34;, errno, strerror(errno)); return -1; } /* 新客户端从等待读事件开始 */ event_register(clientfd, EPOLLIN); /* 每当 fd 到达 1000 的整数倍，打印一次时间间隔 */ if ((clientfd % 1000) == 0) { struct timeval current; gettimeofday(\u0026amp;current, NULL); int time_used = TIME_SUB_MS(current, begin); memcpy(\u0026amp;begin, \u0026amp;current, sizeof(struct timeval)); printf(\u0026#34;accept finshed: %d, time_used: %d\\n\u0026#34;, clientfd, time_used); } return 0; } 4.7 接收请求并进入 KV 协议 int recv_cb(int fd) { /* 本次接收前清空该连接的读缓冲区 */ memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH); /* 从客户端接收数据 */ int count = recv( fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0 ); if (count == 0) { /* recv 返回 0：客户端断开 */ printf(\u0026#34;client disconnect: %d\\n\u0026#34;, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); return 0; } else if (count \u0026lt; 0) { /* recv 返回负数：打印错误并结束连接 */ printf(\u0026#34;count: %d, errno: %d, %s\\n\u0026#34;, count, errno, strerror(errno)); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); return 0; } /* 保存这次实际收到的请求长度 */ conn_list[fd].rlength = count; #if 0 /* echo 分支：直接把读缓冲复制到写缓冲 */ conn_list[fd].wlength = conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength); #elif ENABLE_HTTP http_request(\u0026amp;conn_list[fd]); #elif ENABLE_WEBSOCKET ws_request(\u0026amp;conn_list[fd]); #elif ENABLE_KVSTORE /* 当前启用的分支：通过 handler 生成 KV 响应 */ kvs_request(\u0026amp;conn_list[fd]); #endif /* 请求处理完成，下一阶段等待发送 */ set_event(fd, EPOLLOUT, 0); return count; } 这里完成了项目中最重要的一次跨层调用：\nrecv_cb → kvs_request(\u0026amp;conn_list[fd]) → kvs_handler(rbuffer, rlength, wbuffer) → kvs_protocol 4.8 发送响应 int send_cb(int fd) { #if ENABLE_HTTP http_response(\u0026amp;conn_list[fd]); #elif ENABLE_WEBSOCKET ws_response(\u0026amp;conn_list[fd]); #elif ENABLE_KVSTORE /* KV 分支当前不对 wbuffer 做额外处理 */ kvs_response(\u0026amp;conn_list[fd]); #endif int count = 0; /* wlength 非零时，把 KV 响应发给客户端 */ if (conn_list[fd].wlength != 0) { count = send( fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0 ); } /* 响应阶段结束，再次等待客户端请求 */ set_event(fd, EPOLLIN, 0); return count; } 4.9 启动 Reactor 主循环 int reactor_start(unsigned short port, msg_handler handler) { printf(\u0026#34;reactor_entry %d\\n\u0026#34;, port); /* 保存由 main 传入的 kvs_protocol */ kvs_handler = handler; /* 创建 epoll 实例 */ epfd = epoll_create(1); /* 从 port 开始连续建立 20 个监听端口 */ for (int i = 0; i \u0026lt; MAX_PORTS; i++) { int sockfd = r_init_server(port + i); /* 监听 fd 可读时执行 accept_cb */ conn_list[sockfd].fd = sockfd; conn_list[sockfd].r_action.recv_callback = accept_cb; /* 将监听 fd 加入 epoll */ set_event(sockfd, EPOLLIN, 1); } /* accept 性能统计的起始时间 */ gettimeofday(\u0026amp;begin, NULL); while (1) { struct epoll_event events[1024] = {0}; /* 阻塞等待，最多取得 1024 个就绪事件 */ int nready = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i \u0026lt; nready; i++) { int connfd = events[i].data.fd; /* * 监听 fd：read callback 是 accept_cb； * 客户端 fd：read callback 是 recv_cb。 */ if (events[i].events \u0026amp; EPOLLIN) { conn_list[connfd] .r_action.recv_callback(connfd); } /* 客户端可写时调用 send_cb */ if (events[i].events \u0026amp; EPOLLOUT) { conn_list[connfd] .send_callback(connfd); } } } } 把整份 Reactor 压缩成一句话：\nepoll 主循环根据 fd 和事件找到回调；读回调接收请求并调用 KV handler，写回调发送 handler 生成的响应。\n5. io_uring Proactor 网络层：proactor.c 5.1 Proactor 总体流程 准备 ACCEPT 操作 ↓ submit 等待完成队列 CQ ↓ ACCEPT 完成 → 得到 connfd → 准备 RECV ↓ READ 完成 → 调 kvs_protocol → 准备 SEND ↓ WRITE 完成 → 再次准备 RECV Reactor 是“事件就绪以后调用 I/O”，这份 io_uring 代码是“先提交具体 I/O，再处理完成结果”。\n5.2 操作类型与操作标记 /* 用于区分 CQE 对应哪一种操作 */ #define EVENT_ACCEPT 0 #define EVENT_READ 1 #define EVENT_WRITE 2 struct conn_info { int fd; // 操作所属的 socket int event; // ACCEPT / READ / WRITE }; #define ENTRIES_LENGTH 1024 // io_uring 队列项参数 #define BUFFER_LENGTH 1024 // 网络缓冲区长度 /* proactor_start 保存 main 传入的 kvs_protocol */ static msg_handler kvs_handler; 每个 SQE 都通过 user_data 带上 conn_info。CQE 返回以后，程序就知道完成的是谁的什么操作。\n5.3 准备 send int set_event_send(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) { /* 从提交队列取得一个 SQE */ struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是 sockfd 的写操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_WRITE, }; /* 将 SQE 填写为 send 请求 */ io_uring_prep_send(sqe, sockfd, buf, len, flags); /* 操作完成时，通过 user_data 找回 fd 和事件类型 */ memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 5.4 准备 recv int set_event_recv(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是 sockfd 的读操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_READ, }; /* 将 SQE 填写为 recv 请求 */ io_uring_prep_recv(sqe, sockfd, buf, len, flags); memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 5.5 准备 accept int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags) { struct io_uring_sqe *sqe = io_uring_get_sqe(ring); /* 标记这是监听 sockfd 的 accept 操作 */ struct conn_info info = { .fd = sockfd, .event = EVENT_ACCEPT, }; /* 将 SQE 填写为 accept 请求 */ io_uring_prep_accept(sqe, sockfd, addr, addrlen, flags); memcpy(\u0026amp;sqe-\u0026gt;user_data, \u0026amp;info, sizeof(struct conn_info)); } 三个函数的结构完全一致，只有 prep 函数和 event 类型不同：\n准备函数 liburing prep 事件标记 set_event_accept io_uring_prep_accept EVENT_ACCEPT set_event_recv io_uring_prep_recv EVENT_READ set_event_send io_uring_prep_send EVENT_WRITE 5.6 创建监听 socket int p_init_server(unsigned short port) { /* 创建 IPv4 TCP socket */ int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(port); /* 绑定端口 */ if (bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)) == -1) { printf(\u0026#34;bind failed: %s\\n\u0026#34;, strerror(errno)); } /* 开始监听 */ listen(sockfd, 10); return sockfd; } 5.7 初始化 io_uring 并准备第一个 accept int proactor_start(unsigned short port, msg_handler handler) { /* 创建监听 socket */ int sockfd = p_init_server(port); /* 保存 kvs_protocol */ kvs_handler = handler; /* 初始化 io_uring 参数 */ struct io_uring_params params; memset(\u0026amp;params, 0, sizeof(params)); /* 创建提交队列和完成队列 */ struct io_uring ring; io_uring_queue_init_params( ENTRIES_LENGTH, \u0026amp;ring, \u0026amp;params ); /* accept 完成后，内核会填写客户端地址 */ struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); /* 在进入循环之前，先准备第一个 accept SQE */ set_event_accept( \u0026amp;ring, sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len, 0 ); /* READ 使用 buffer，KV 输出和 WRITE 使用 response */ char buffer[BUFFER_LENGTH] = {0}; char response[BUFFER_LENGTH] = {0}; 5.8 提交和取得完成事件 while (1) { /* 把当前已经准备好的 SQE 提交给 io_uring */ io_uring_submit(\u0026amp;ring); /* 至少等待一个操作完成 */ struct io_uring_cqe *cqe; io_uring_wait_cqe(\u0026amp;ring, \u0026amp;cqe); /* 一次批量取出最多 128 个完成项 */ struct io_uring_cqe *cqes[128]; int nready = io_uring_peek_batch_cqe( \u0026amp;ring, cqes, 128 ); for (int i = 0; i \u0026lt; nready; i++) { struct io_uring_cqe *entry = cqes[i]; /* 从 user_data 还原这个操作的 fd 和类型 */ struct conn_info result; memcpy(\u0026amp;result, \u0026amp;entry-\u0026gt;user_data, sizeof(struct conn_info)); 这里的两个信息来源要分清：\nresult.fd / result.event 提交 SQE 时由应用保存 用来识别“谁的什么操作” entry-\u0026gt;res 操作完成时由内核返回 用来表示 accept 的新 fd、recv 长度或 send 结果 5.9 分发 ACCEPT、READ 和 WRITE 完成事件 if (result.event == EVENT_ACCEPT) { /* * 当前 accept 已完成； * 先准备下一个 accept，保持继续接收新连接。 */ set_event_accept( \u0026amp;ring, sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len, 0 ); /* accept CQE 的 res 是新客户端 fd */ int connfd = entry-\u0026gt;res; /* 为新客户端准备第一次 recv */ set_event_recv( \u0026amp;ring, connfd, buffer, BUFFER_LENGTH, 0 ); } else if (result.event == EVENT_READ) { /* recv CQE 的 res 是本次收到的字节数 */ int ret = entry-\u0026gt;res; if (ret == 0) { /* 客户端关闭连接 */ close(result.fd); } else if (ret \u0026gt; 0) { /* * buffer + ret 构成请求； * response 接收 KV 协议输出； * 返回值重新赋给 ret，作为响应长度。 */ ret = kvs_handler(buffer, ret, response); /* 准备把 KV 响应发送给原客户端 */ set_event_send( \u0026amp;ring, result.fd, response, ret, 0 ); } } else if (result.event == EVENT_WRITE) { /* send CQE 的 res 是写操作完成结果 */ int ret = entry-\u0026gt;res; /* 本轮响应结束，再次等待该客户端请求 */ set_event_recv( \u0026amp;ring, result.fd, buffer, BUFFER_LENGTH, 0 ); } } /* 告诉 ring：这一批 CQE 已经处理完 */ io_uring_cq_advance(\u0026amp;ring, nready); } } 把整份 Proactor 压缩成一句话：\n每处理一个完成事件，就准备这个连接的下一项操作；READ 完成与 WRITE 准备之间插入一次 KV handler 调用。\n6. ntyco 协程网络层：ntyco.c 6.1 ntyco 在项目中的结构 ntyco_start ↓ 创建 server 协程：socket → bind → listen → accept ↓ 每个客户端创建 server_reader 协程：recv → kvs_protocol → send 这里不展开 nty_coroutine_create 和 nty_schedule_run 的内部实现，只观察项目怎样调用框架。\n6.2 客户端处理协程 /* ntyco_start 保存 main 传入的 kvs_protocol */ static msg_handler kvs_handler; void server_reader(void *arg) { /* server 创建协程时传入的是 cli_fd 地址 */ int fd = *(int *)arg; int ret = 0; while (1) { /* 每轮准备一个新的接收缓冲区 */ char buf[1024] = {0}; /* 接收这个客户端的请求 */ ret = recv(fd, buf, 1024, 0); if (ret \u0026gt; 0) { /* 为本轮响应准备缓冲区 */ char response[1024] = {0}; /* * buf + ret 是请求； * response 是 KV 输出； * slength 是响应长度。 */ int slength = kvs_handler(buf, ret, response); /* 将 KV 响应发送给当前客户端 */ ret = send(fd, response, slength, 0); if (ret == -1) { close(fd); break; } } else if (ret == 0) { /* 客户端正常关闭连接 */ close(fd); break; } } } 与 Reactor、Proactor 相比，协程版本把同一个客户端的处理过程写在一个顺序循环里：\nrecv(...); kvs_handler(...); send(...); 6.3 监听协程 void server(void *arg) { /* 还原 ntyco_start 传入的端口 */ unsigned short port = *(unsigned short *)arg; /* 创建 IPv4 TCP 监听 socket */ int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd \u0026lt; 0) return; /* 填写本地监听地址 */ struct sockaddr_in local, remote; local.sin_family = AF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = INADDR_ANY; /* 绑定并监听 */ bind(fd, (struct sockaddr *)\u0026amp;local, sizeof(struct sockaddr_in)); listen(fd, 20); printf(\u0026#34;listen port : %d\\n\u0026#34;, port); while (1) { socklen_t len = sizeof(struct sockaddr_in); /* 接受一个客户端连接 */ int cli_fd = accept( fd, (struct sockaddr *)\u0026amp;remote, \u0026amp;len ); /* * 每个客户端创建一个 server_reader 协程； * 该协程负责此后的 recv、KV 处理和 send。 */ nty_coroutine *read_co; nty_coroutine_create( \u0026amp;read_co, server_reader, \u0026amp;cli_fd ); } } 6.4 ntyco 网络层入口 int ntyco_start(unsigned short port, msg_handler handler) { /* 保存 kvs_protocol */ kvs_handler = handler; /* 创建负责监听和 accept 的 server 协程 */ nty_coroutine *co = NULL; nty_coroutine_create(\u0026amp;co, server, \u0026amp;port); /* 启动 ntyco 调度器 */ nty_schedule_run(); } 把 ntyco 版本压缩成一句话：\nserver 协程负责产生连接，每个连接再由一个 reader 协程按顺序完成接收、协议处理和发送。\n7. 三种网络层放在一起回顾 7.1 同一阶段的代码对应关系 阶段 Reactor io_uring Proactor ntyco 保存 KV 函数 kvs_handler = handler kvs_handler = handler kvs_handler = handler 等待连接 监听 fd 的 EPOLLIN 提交 ACCEPT SQE server 协程调用 accept 获得连接 accept_cb() EVENT_ACCEPT CQE accept() 返回 等待请求 客户端 fd 的 EPOLLIN 提交 RECV SQE reader 协程调用 recv KV 接入点 kvs_request() EVENT_READ 分支 server_reader() 发送响应 send_cb() 提交 SEND SQE reader 协程调用 send 继续循环 改回 EPOLLIN WRITE 完成后再提交 RECV while 回到 recv 7.2 一次请求的三条完整路径 Reactor epoll_wait → EPOLLIN → recv_cb → conn.rbuffer / conn.rlength → kvs_request → kvs_protocol → conn.wbuffer / conn.wlength → EPOLLOUT → send_cb → EPOLLIN Proactor 提交 RECV → READ CQE → buffer / entry-\u0026gt;res → kvs_protocol → response / 响应长度 → 提交 SEND → WRITE CQE → 再提交 RECV ntyco server_reader 协程 → recv → buf / ret → kvs_protocol → response / slength → send → 下一轮 recv 7.3 两种回调不要混淆 /* Reactor 内部的网络事件回调 */ typedef int (*RCALLBACK)(int fd); /* 三种网络层共同使用的 KV 协议回调 */ typedef int (*msg_handler)(char *msg, int length, char *response); 它们的层次是：\nReactor 中： epoll → RCALLBACK → recv_cb → msg_handler → kvs_protocol Proactor 中： CQE → EVENT_READ 分支 → msg_handler → kvs_protocol ntyco 中： server_reader → msg_handler → kvs_protocol 8. 最后的整体记忆 重新回顾这个项目时，只需要先恢复四个关键词：\nNETWORK_SELECT 决定使用 Reactor、Proactor 还是 ntyco msg_handler 把网络层与 KV 协议层连接起来 kvs_protocol 接收请求、生成响应，当前实现为 echo 网络事件循环 三种模型分别用 epoll、io_uring、协程完成连接和收发 整个程序的共同主干是：\n/* main 把协议函数交给网络层 */ network_start(port, kvs_protocol); /* 网络层保存函数地址 */ kvs_handler = handler; /* 网络层收到请求后调用它 */ response_len = kvs_handler( request, request_len, response ); /* 网络层按返回长度发送响应 */ send(clientfd, response, response_len, 0); 这四步就是六份代码共同围绕的核心。三种网络模型改变的是“请求怎样到达这四步、响应怎样离开这四步”，而 KV 协议入口保持一致。\n","date":"2026-08-22T00:00:00+08:00","image":"/MyBlog/p/kv-store-network-layer/cover.svg","permalink":"/MyBlog/p/kv-store-network-layer/","title":"KV 存储项目网络层：统一接入 Reactor、Proactor 与协程"},{"content":"一、学习目标 这部分学习主要围绕两个目标展开：\n编写一个 TCP 客户端测试工具，对比 epoll 和 io_uring 服务端的性能。 整理网络相关面试题，重点理解 TCP、UDP、建链、断链、并发及分包粘包问题。 二、QPS 测试工具需求 客户端需要支持以下参数：\n-n request_num 请求总数 -t thread_num 线程数量 -c connection_num 连接数量 -s server_ip 服务端 IP -p server_port 服务端端口 使用示例：\n./test_qps_tcpclient \\ -s 172.16.145.129 \\ -p 2048 \\ -t 50 \\ -c 100 \\ -n 10000 测试采用 Echo 模型：\n客户端向服务端发送一段数据。 服务端将数据原样返回。 客户端比较发送和接收的数据。 数据一致则请求成功，否则失败。 最后统计总耗时和 QPS。 QPS 的计算方式为：\nQPS = 完成的请求数 ÷ 耗时（秒） 代码中使用毫秒计时，因此写成：\nqps = request_num * 1000 / time_used_ms; 三、测试数据的构造 测试字符串为：\n#define TEST_MESSAGE \\ \u0026#34;ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\\r\\n\u0026#34; 这段字符串的长度正好是 64 字节：\n26 个大写字母 + 10 个数字 + 26 个小写字母 + \\r\\n 两个字符 = 64 字节 通过重复复制，可以构造不同大小的请求：\nfor (i = 0; i \u0026lt; 2; i++) { strcpy( wbuffer + i * strlen(TEST_MESSAGE), TEST_MESSAGE ); } 重复次数与数据长度的关系如下：\n重复次数 数据长度 1 64 字节 2 128 字节 4 256 字节 8 512 字节 16 1024 字节 测试大包时，需要关闭服务端和客户端的数据打印，否则终端输出本身会严重影响测试结果。\n四、64 bytes 测试 测试 100 万次、每次发送 64 bytes 时，结果如下：\n数据长度 服务端模型 成功 失败 耗时 QPS 64 bytes epoll 1000000 0 5132 ms 194855 64 bytes io_uring 1000000 0 4400 ms 227272 在 64 bytes 测试中：\nepoll QPS：194855 io_uring QPS：227272 io_uring 的 QPS 比 epoll 高约 16.6%。\n五、不同数据长度下的测试 1. io_uring 服务端 数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 4337 ms 230574 256 bytes 1000000 0 4268 ms 234301 512 bytes 1000000 0 4307 ms 232180 1024 bytes 1000000 0 4390 ms 227790 从结果来看，在 128～1024 bytes 范围内，io_uring 的 QPS 比较稳定，基本维持在 22 万～23 万。\n1024 bytes 测试时，笔记中怀疑可能出现“粘包”。\n更准确地说，TCP 本身是字节流，没有一条 send() 必须对应一条 recv() 的保证。一次发送的 1024 bytes，可能需要多次 recv() 才能完整读取，因此客户端必须实现循环接收。\n2. epoll 服务端——阻塞 I/O 数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 74935 ms 13344 256 bytes 1000000 0 147749 ms 6768 512 bytes 1000000 0 296207 ms 3376 1024 bytes 1000000 0 721163 ms 1386 这组结果明显异常：数据长度每增加一倍，耗时几乎也增加一倍，QPS 则接近减半。\n问题在于 epoll 服务端使用了阻塞 socket。\nepoll 的作用是通知程序某个 fd 当前可读或者可写。但即使 fd 已经可读，也不代表接下来所有的 recv() 都不会阻塞。\n尤其是在循环读取时，如果已经把当前到达的数据读完，又调用了一次阻塞式 recv()，整个事件循环就可能停在某一个连接上，导致其他连接无法及时处理。\n所以 epoll 一般需要与非阻塞 socket 配合：\nepoll 通知 fd 可读 ↓ 循环调用 recv ↓ 读取当前已经到达的数据 ↓ recv 返回 EAGAIN ↓ 处理下一个就绪事件 3. epoll 服务端——非阻塞 I/O 将 socket 设置成非阻塞后重新测试：\n数据长度 成功 失败 耗时 QPS 128 bytes 1000000 0 5198 ms 192381 256 bytes 1000000 0 5144 ms 194401 512 bytes 1000000 0 5141 ms 194514 1024 bytes 1000000 0 5216 ms 191717 修改后，epoll 的 QPS 恢复到约 19 万，并且不再随数据长度增加而大幅下降。\n这说明前一组数据反映的主要不是 epoll 本身的性能，而是“epoll 配合阻塞 I/O”造成的异常。\n六、epoll 与 io_uring 的完整对比 数据长度 io_uring QPS epoll QPS 64 bytes 227272 194855 128 bytes 230574 192381 256 bytes 234301 194401 512 bytes 232180 194514 1024 bytes 227790 191717 从测试结果来看：\n64 bytes 时，io_uring 比 epoll 高约 16.6%； 128～1024 bytes 时，io_uring 大约比 epoll 高 19%～21%； 所有测试包大小下，io_uring 的 QPS 都高于 epoll； 两种模型在改正 epoll 的阻塞 I/O 问题后，QPS 都没有随着数据大小增加而发生明显下降。 二者的处理思路不同。\nepoll epoll 是就绪通知模型：\n先等待 fd 可读或可写 → 得到就绪事件 → 应用程序再调用 recv/send 也就是说，epoll 负责告诉应用程序“现在可以尝试进行 I/O”，真正的数据读写仍然由应用程序发起。\nio_uring io_uring 更接近完成通知模型：\n应用程序先提交 recv/send 请求 → 内核执行 I/O → I/O 完成后通知应用程序 应用程序可以提前向内核提交 I/O 请求，然后通过完成队列取得结果。\n根据本次测试，可以得到以下结论：\n在当前服务端实现、当前客户端模型和当前测试环境下，io_uring 的 QPS 全面高于非阻塞 epoll，整体高约 17%～21%。\n这个结论应当限定在本次测试条件内，不能仅凭这一轮测试认为所有业务中 io_uring 都一定优于 epoll。\n七、业务中的数据包大小 实际业务中可能同时存在小包和大包。\n1. 心跳包 客户端和服务端之间通常会存在心跳包。\n心跳包只用于检测连接是否仍然存活，因此数据量通常很小，可能只有几个字节。\n这类场景更关注：\n大量连接的管理能力； 小包的处理效率； I/O 事件通知和调度开销； 服务端是否能够及时发现断开的连接。 2. 视频和图片传输 视频或者较大的图片需要被拆分成多个数据块。\n应用层可以将每一个业务包组织成约 1 KB，再通过协议头标识数据长度、类型和序号。\n这类场景除了 QPS，还需要关注：\n吞吐量； 单次发送的数据大小； 分包和组包； 内存复制； 慢连接对事件循环的影响。 因此，也可以结合不同数据大小下的测试结果，理解 epoll 和 io_uring 的差别。\n3. 百万并发测试 后续还可以使用 io_uring 和 epoll 测试百万并发，包括：\n建立相同数量连接所需要的时间； 相同并发连接数下的 QPS； 百万连接下的内存占用； 建链事件的处理能力； 断链事件的处理能力。 断开连接涉及 TCP 协议栈中的状态转换，但应用层仍然需要正确执行 close()、移除连接状态，并释放自己管理的连接资源。\n八、QPS 客户端代码详解 1. 测试上下文 struct test_context_s { char serverip[16]; int port; int threadnum; int connection; int requestion; #if 1 int failed; #endif }; typedef struct test_context_s test_context_t; 这个结构保存测试参数：\nserverip：服务端 IPv4 地址； port：服务端端口； threadnum：测试线程数； connection：期望建立的连接数量； requestion：请求总数； failed：失败请求数。 requestion 想表达的应该是请求数量，也就是 request_count 或者 requests，只是命名不太准确，不影响程序运行。\nserverip 的长度是 16，刚好可以保存最长的 IPv4 字符串和字符串结尾的 \\0：\n255.255.255.255\\0 不过，代码使用的是：\nstrcpy(ctx.serverip, optarg); 如果传入的参数超过数组长度，就会发生越界写入。\n2. 建立 TCP 连接 int connect_tcpserver( const char *ip, unsigned short port ) 函数首先创建 TCP socket：\nint connfd = socket(AF_INET, SOCK_STREAM, 0); 其中：\nAF_INET 表示使用 IPv4； SOCK_STREAM 表示使用 TCP； 返回值 connfd 是连接对应的文件描述符。 然后构造服务端地址：\nstruct sockaddr_in tcpserver_addr; memset( \u0026amp;tcpserver_addr, 0, sizeof(struct sockaddr_in) ); 清零后设置地址类型：\ntcpserver_addr.sin_family = AF_INET; 设置服务端 IP：\ntcpserver_addr.sin_addr.s_addr = inet_addr(ip); inet_addr() 将类似下面的 IPv4 字符串：\n172.16.145.129 转换成网络地址。\n设置端口：\ntcpserver_addr.sin_port = htons(port); htons() 将端口从主机字节序转换成网络字节序。\n最后调用：\nint ret = connect( connfd, (struct sockaddr *)\u0026amp;tcpserver_addr, sizeof(struct sockaddr_in) ); 如果连接成功，返回 connfd：\nreturn connfd; 如果连接失败，返回 -1：\nif (ret) { perror(\u0026#34;connect\\n\u0026#34;); return -1; } 当前代码没有检查 socket() 是否创建失败。\n另外，如果 connect() 失败，已经创建的 connfd 没有关闭，会造成文件描述符泄漏。\n3. 时间计算 #define TIME_SUB_MS(tv1, tv2) \\ ((tv1.tv_sec - tv2.tv_sec) * 1000 + \\ (tv1.tv_usec - tv2.tv_usec) / 1000) 这个宏用来计算两个 timeval 之间相差的毫秒数：\n秒差 × 1000 + 微秒差 ÷ 1000 主函数在线程创建前记录开始时间：\nstruct timeval tv_begin; gettimeofday(\u0026amp;tv_begin, NULL); 所有线程结束后记录结束时间：\nstruct timeval tv_end; gettimeofday(\u0026amp;tv_end, NULL); 然后计算：\nint time_used = TIME_SUB_MS(tv_end, tv_begin); 因此，当前统计结果包含：\n创建线程的时间； 建立 TCP 连接的时间； 发送和接收请求的时间； 等待线程退出的时间。 所以当前 QPS 是一轮完整压测的整体 QPS，并不是纯粹的数据收发 QPS。\n如果后续需要分别测试建链性能和请求处理性能，就需要把建立连接和发送请求分开计时。\n4. 测试消息 #define TEST_MESSAGE \\ \u0026#34;ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyz\\r\\n\u0026#34; #define RBUFFER_LENGTH 2048 #define WBUFFER_LENGTH 2048 TEST_MESSAGE 是 64 bytes。\n读缓冲区和写缓冲区都设置为 2048 bytes。\n在当前代码中，WBUFFER_LENGTH 宏并没有被使用，因为写缓冲区直接写成了：\nchar wbuffer[2048] = {0}; 5. 单次请求过程 int send_recv_tcppkt(int fd) 这个函数完成一次请求和响应。\n构造发送数据 char wbuffer[2048] = {0}; int i = 0; for (i = 0; i \u0026lt; 2; i++) { strcpy( wbuffer + i * strlen(TEST_MESSAGE), TEST_MESSAGE ); } 由于 TEST_MESSAGE 为 64 bytes，循环两次后，wbuffer 中保存了 128 bytes 数据。\n第一次复制的位置是：\nwbuffer + 0 第二次复制的位置是：\nwbuffer + 64 因此，两段 64 bytes 的数据首尾相连，组成 128 bytes 的请求。\n发送数据 int res = send( fd, wbuffer, strlen(wbuffer), 0 ); 如果发送失败：\nif (res \u0026lt; 0) { exit(1); } 这里的主要问题是，一次 send() 不保证发送完全部数据。\n例如：\nsend(fd, buffer, 1024, 0); 返回值可能是：\n1024：全部发送完成 512：只发送了 512 bytes -1：发送失败 所以不能只判断 res \u0026lt; 0，还需要判断返回值是否等于预期发送长度。\n正确的逻辑应当是：\n记录总共需要发送的长度 ↓ 循环调用 send ↓ 每次根据返回值移动缓冲区指针 ↓ 直到所有字节发送完成 接收数据 char rbuffer[RBUFFER_LENGTH] = {0}; res = recv( fd, rbuffer, RBUFFER_LENGTH, 0 ); 如果返回值小于或者等于 0：\nif (res \u0026lt;= 0) { exit(1); } recv() 的返回值含义是：\n大于 0：本次实际读取的字节数 等于 0：对端正常关闭连接 小于 0：读取失败 和 send() 一样，一次 recv() 也不保证读取到完整的响应。\n服务端即使完整返回了 1024 bytes，客户端也可能出现：\n第一次 recv：读取 300 bytes 第二次 recv：读取 500 bytes 第三次 recv：读取 224 bytes 因此，需要循环接收，直到读取到预期长度。\n这正是笔记中“很有必要重新封装一个 recv 和 send”的原因。\n比较数据 if (strcmp(rbuffer, wbuffer) != 0) { printf( \u0026#34;failed: \u0026#39;%s\u0026#39;!=\u0026#39;%s\u0026#39;\\n\u0026#34;, rbuffer, wbuffer ); return -1; } 这里通过 strcmp() 比较接收数据和发送数据。\n但网络数据本质上是一段带长度的二进制数据，使用 strcmp() 的前提是两个缓冲区都以 \\0 结尾。\n更稳定的比较方式是根据实际数据长度使用：\nmemcmp(rbuffer, wbuffer, expected_length); 如果一次 recv() 只读到部分数据，那么当前 strcmp() 会直接认为请求失败，即使剩余数据稍后还能继续收到。\n6. 工作线程 static void *test_qps_entry(void *arg) 线程首先取得测试上下文：\ntest_context_t *pctx = (test_context_t *)arg; 原始笔记中出现了：\n(test\\_context\\_t *)arg 如果实际代码中确实包含反斜杠，需要将反斜杠删除。它也可能只是复制代码时产生的 Markdown 转义。\n计算每个线程的连接数 int conn_num = pctx-\u0026gt;connection / pctx-\u0026gt;threadnum; 这里理论上表示每个线程需要管理多少个连接。\n例如：\n连接数：100 线程数：50 每个线程应当管理：2 个连接 但是，conn_num 后面没有被使用。\n实际代码只建立了一次连接：\nint connfd = connect_tcpserver( pctx-\u0026gt;serverip, pctx-\u0026gt;port ); 因此，每个线程只建立一个连接。\n这意味着：\n-t 50 -c 100 实际建立的不是 100 个连接，而是 50 个连接。\n当前代码中的 -c 参数实际上没有生效。\n计算每个线程的请求数 int count = pctx-\u0026gt;requestion / pctx-\u0026gt;threadnum; 例如：\n请求总数：10000 线程数：50 每个线程：200 次请求 每个线程执行：\nwhile (i++ \u0026lt; count) { res = send_recv_tcppkt(connfd); if (res != 0) { printf(\u0026#34;send_recv_tcppkt failed\\n\u0026#34;); pctx-\u0026gt;failed++; continue; } } 线程中的请求模型为：\n发送一次请求 ↓ 阻塞等待响应 ↓ 收到完整响应 ↓ 再发送下一次请求 所以每个连接同一时间最多只有一个未完成请求。\n整个客户端能够同时处理的请求数量，实际上接近线程数量，而不是 -c 指定的连接数量。\n请求数量不能整除线程数 如果：\n-n 10003 -t 50 则：\ncount = 10003 / 50; 整数除法结果为：\n200 实际发送请求数为：\n200 × 50 = 10000 剩余 3 次请求不会发送。\n但是，程序最后仍然按照 10003 计算成功数量和 QPS，所以统计结果会不准确。\n7. 失败请求统计 失败时执行：\npctx-\u0026gt;failed++; 所有线程共享同一个 failed，但这里没有加锁，也没有使用原子变量。\n多个线程可能同时执行：\npctx-\u0026gt;failed++; 例如，两个线程同时读到：\nfailed = 10 两个线程都计算得到 11，再分别写回，最终结果可能仍然是 11，而不是正确的 12。\n因此，当前失败数具有以下特点：\n可以粗略判断是否出现了失败； 不能保证失败数量准确； 多线程同时修改时存在数据竞争。 笔记中提到“数据没有那么重要，只需要知道有失败的可能性”，这种处理可以用于临时测试，但最终版本如果需要准确统计失败数，仍然需要使用锁或者原子操作。\n8. 命令行参数解析 主函数通过 getopt() 解析参数：\nwhile ( (opt = getopt( argc, argv, \u0026#34;s:p:t:c:n:?\u0026#34; )) != -1 ) 参数含义如下：\n-s：服务端 IP -p：服务端端口 -t：线程数量 -c：连接数量 -n：请求数量 例如：\ncase \u0026#39;s\u0026#39;: strcpy(ctx.serverip, optarg); break; 读取 IP。\ncase \u0026#39;p\u0026#39;: ctx.port = atoi(optarg); break; 读取端口。\ncase \u0026#39;t\u0026#39;: ctx.threadnum = atoi(optarg); break; 读取线程数量。\ncase \u0026#39;c\u0026#39;: ctx.connection = atoi(optarg); break; 读取连接数量。\ncase \u0026#39;n\u0026#39;: ctx.requestion = atoi(optarg); break; 读取请求总数。\ngetopt() 使用了一些全局状态，因此笔记中记录它不是线程安全函数。不过当前代码只在主线程、创建工作线程之前调用，所以不会产生并发调用问题。\n9. 创建工作线程 程序首先根据线程数量申请线程 ID 数组：\npthread_t *ptid = malloc( ctx.threadnum * sizeof(pthread_t) ); 然后记录开始时间：\nstruct timeval tv_begin; gettimeofday(\u0026amp;tv_begin, NULL); 创建工作线程：\nfor (i = 0; i \u0026lt; ctx.threadnum; i++) { pthread_create( \u0026amp;ptid[i], NULL, test_qps_entry, \u0026amp;ctx ); } 所有线程都接收同一个 ctx 地址：\n\u0026amp;ctx 其中：\nIP、端口、线程数、连接数和请求数只读； failed 会被所有线程共同修改。 创建完成后，主线程等待所有工作线程退出：\nfor (i = 0; i \u0026lt; ctx.threadnum; i++) { pthread_join(ptid[i], NULL); } 只有全部线程执行完毕，主线程才会继续计算总耗时。\n10. 统计 QPS 所有线程结束后，记录结束时间：\nstruct timeval tv_end; gettimeofday(\u0026amp;tv_end, NULL); 计算总耗时：\nint time_used = TIME_SUB_MS(tv_end, tv_begin); 最后输出：\nprintf( \u0026#34;success: %d,failed:%d,\u0026#34; \u0026#34;time_used:%d,qps:%d\\n\u0026#34;, ctx.requestion - ctx.failed, ctx.failed, time_used, ctx.requestion * 1000 / time_used ); 输出内容包括：\n成功请求数； 失败请求数； 总耗时； QPS。 当前 QPS 使用的是：\nctx.requestion * 1000 / time_used 这里使用了配置的请求数，而不是实际完成的请求数。\n如果请求数不能整除线程数，或者中间出现失败，QPS 就不能准确表示真正成功完成的请求数量。\n11. 资源释放 主函数结束前释放线程数组：\nfree(ptid); 但工作线程建立的 TCP 连接没有关闭：\nclose(connfd); 在线程退出前，应当关闭自己创建的连接。\n虽然进程退出时，操作系统最终会回收文件描述符，但如果后续让一个进程连续执行多轮测试，不关闭连接就会逐渐产生资源泄漏。\n九、当前客户端的实际测试模型 虽然命令行参数中包含 -c，但当前代码的实际模型是：\n主线程 ├─ 工作线程 1 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ ├─ 工作线程 2 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ ├─ 工作线程 3 │ └─ 一个 TCP 连接 │ └─ 串行发送请求 │ └─ 其他工作线程 └─ 每个线程一个 TCP 连接 因此：\n实际连接数 = 线程数 每个连接同时只有一个请求 -c 参数没有生效 当前工具比较的是：\n多线程、每个线程使用一个阻塞 TCP 连接、请求与响应严格交替时，epoll 和 io_uring 服务端的处理能力。\n它还不能测试固定线程数管理大量连接的能力，也不能直接用于百万并发测试。\n如果需要让 -c 生效，每个线程就要创建：\nconnection / threadnum 个连接，并在这些连接之间分配请求。\n十、当前代码需要优先处理的问题 按照对测试准确性的影响排序：\n-c 参数没有生效，每个线程实际上只建立一个连接。 send() 没有循环处理短写。 recv() 没有循环处理短读。 请求数不能整除线程数时，实际请求数量少于 -n。 failed++ 存在多线程数据竞争。 工作线程结束后没有执行 close(connfd)。 QPS 使用配置的请求数计算，而不是实际完成数。 没有检查 socket()、malloc() 和 pthread_create() 的返回值。 connect() 失败后没有关闭已经创建的 socket。 工作线程中的 exit(1) 会让一个线程的网络错误终止整个压测进程。 如果源代码确实包含 test\\_context\\_t，需要删除反斜杠。 如果源代码第一行确实是 include\u0026lt;stdio.h\u0026gt;，需要补上 #。 十一、网络面试问题整理 原始笔记目前记录了四个核心问题，没有足够内容整理成十个，因此这里只整理已有内容，不额外补题。\n1. TCP 三次握手与四次挥手 三次握手 客户端 服务端 SYN ------------------------\u0026gt; SYN + ACK \u0026lt;------------------------ ACK ------------------------\u0026gt; 三次握手需要确认：\n客户端能够发送； 服务端能够接收； 服务端能够发送； 客户端能够接收； 双方同步初始序列号。 之所以不能只使用两次握手，是因为服务端发送 SYN + ACK 后，还需要知道客户端是否真正收到了自己的响应。\n第三次 ACK 让服务端确认：\n客户端已经收到服务端的 SYN 客户端也能够正常接收数据 2. 为什么建链是三次，断链通常是四次 建立连接时，服务端可以把两个操作合并：\n确认客户端的 SYN + 发送自己的 SYN 因此，服务端可以通过一个 SYN + ACK 同时完成这两个操作，最终形成三次握手。\n断开连接时：\n收到 FIN 后必须先回复 ACK； 但是本端可能还有数据没有发送完； 等剩余数据发送完成后，才能发送自己的 FIN。 因此，ACK 和 FIN 通常不能立即合并，形成四次挥手。\n四次挥手 主动关闭方 被动关闭方 FIN ------------------------\u0026gt; ACK \u0026lt;------------------------ FIN \u0026lt;------------------------ ACK ------------------------\u0026gt; TCP 是全双工连接，两个方向需要分别关闭。\n一方发送 FIN，只表示自己不再发送数据，并不表示对方也已经没有数据可发。\n如果对方收到 FIN 时，刚好也没有剩余数据需要发送，那么 ACK 和 FIN 也可能合并。\n3. UDP 的并发如何实现 UDP 没有 TCP 中的：\nlisten accept connection fd 客户端通过：\nsendto() 向服务端指定的 IP 和端口发送数据。\n服务端通过：\nrecvfrom() 接收数据，同时得到客户端的 IP 和端口。\n假设有 100 万个客户端同时向同一个 UDP 端口发送数据，内核会按照数据报把数据放入 socket 的接收队列。\n服务端每次调用 recvfrom() 时，可以获得：\n一条 UDP 数据报； 发送方的 IP； 发送方的端口。 因此，不会因为多个客户端共用一个服务端端口，就直接把不同客户端的数据拼接成一条“脏数据”。\n应用层可以使用：\n客户端 IP + 客户端端口 标识一个逻辑会话。\n笔记中提出了一种模拟 TCP 的管理方式：\n客户端第一次向服务端发送握手包； 服务端得到客户端的 IP 和端口； 服务端为这个客户端建立逻辑会话； 后续通过会话信息管理双方的数据； 如果需要分散压力，可以使用多个服务端端口或者多个 UDP socket。 例如：\n2000 个端口 每个端口管理约 1000 个逻辑连接 总计约 200 万个逻辑连接 但是，不一定需要为每个 UDP 客户端单独分配一个端口。\nUDP 的并发数量也不是直接受 65535 个端口限制。多个客户端可以通过不同的来源 IP 和来源端口访问同一个服务端端口。\n这一部分需要继续整理成一篇独立的技术笔记：\nUDP 的并发实现与应用层会话管理。\n4. TCP 和 UDP 有哪些区别 连接方式 TCP 是面向连接的协议。\n通信前需要建立连接：\n客户端连接 fd ←→ 服务端连接 fd 服务端通过 accept() 为每个客户端创建连接 fd，因此每条 TCP 连接都有独立的连接状态。\nUDP 是无连接协议。\n客户端不需要先建立 TCP 意义上的连接，可以直接通过 sendto() 发包，服务端通过 recvfrom() 接收。\n数据形式 TCP 是字节流协议。\n它传输的是连续字节流，本身不保留应用层的消息边界。\nUDP 是数据报协议。\n每次 sendto() 发送一条 UDP 数据报，接收端通过 recvfrom() 接收一条数据报。UDP 会保留数据报边界，但如果接收缓冲区太小，超出的部分可能被截断。\n可靠性和顺序 TCP 提供：\n有序传输； 丢包重传； 流量控制； 拥塞控制。 先发送的数据，在连接正常的情况下，会先交付给接收方应用程序。\nUDP 本身不保证：\n数据一定到达； 数据按照发送顺序到达； 数据不会重复； 丢失的数据会自动重传。 如果业务需要这些能力，就需要在 UDP 的应用层加入：\n包 ID； 数据序号； ACK 确认； 超时重传； 去重； 乱序重排。 例如，把一份大数据拆成多个 UDP 包时，可以在用户空间定义：\n消息 ID 分片序号 总分片数量 接收端根据 ID 和序号判断当前收到的是第几个包，并将多个数据包重新组装。\n5. TCP 的分包与粘包 TCP 是字节流。\n应用层调用一次：\nsend(fd, buffer, 1024, 0); 并不代表接收端一定通过一次：\nrecv(fd, buffer, 1024, 0); 得到完整数据。\n可能出现：\n一次 send → 多次 recv 多次 send → 一次 recv 所以所谓的 TCP 分包和粘包，本质上是应用层没有明确消息边界。\n常见解决方法有两种。\n方法一：长度字段 在应用数据的头部记录消息体长度：\n| 固定长度的消息头 | 消息体 | 例如使用前两个或者前四个字节表示长度：\n| 2 bytes 长度 | 实际数据 | 接收端先读取固定长度的消息头：\n先循环读取 2 bytes ↓ 解析出消息体长度 ↓ 再循环 recv ↓ 读取完整消息体 由于 TCP 保证字节流有序，因此接收端可以按照这个顺序解析数据。\n方法二：分隔符 每条消息后面增加一个特殊分隔符，例如：\n\\r\\n\\r\\n 接收端不断读取数据并放入缓冲区，然后查找分隔符：\n收到数据 ↓ 加入接收缓冲区 ↓ 查找 \\r\\n\\r\\n ↓ 找到后切出一条完整消息 如果消息正文中也可能出现相同分隔符，就需要进行转义，或者使用长度字段。\n6. TCP 和 UDP 的并发区别 TCP 服务端通常采用：\nlisten socket ↓ accept ↓ 每个客户端对应一个连接 fd ↓ 使用 epoll 管理大量连接 fd TCP 协议栈已经维护了每条连接的状态，因此应用层可以通过不同 fd 区分客户端。\nUDP 没有为每个客户端创建独立的连接 fd。\n服务端一般根据：\n来源 IP 来源端口 应用层会话 ID 管理客户端状态。\n因此，不是说 UDP 不能做高并发，而是 UDP 需要在应用层建立自己的会话管理方式。\n7. UDP 的使用场景 UDP 更适合以下场景：\n实时音视频； 对实时性要求较高的游戏数据； DNS 等一次请求、一次响应的场景； 能够容忍少量丢包的场景； 由应用层自己控制可靠性的场景。 例如在游戏团战中，玩家位置、技能释放和实时状态对延迟比较敏感。\n如果一个旧的位置数据由于重传很晚才到达，这个数据可能已经失去意义。因此，这类实时状态通常更偏向使用 UDP。\n但是，“对战游戏没有使用 TCP”这个说法过于绝对。\n更合适的理解是：\n游戏中强调实时性的位置、动作等消息更倾向使用 UDP；登录、充值、聊天等要求可靠的数据仍然可能使用 TCP。\n8. TCP 的使用场景 TCP 更适合：\n文件传输； 需要完整交付的数据； 需要严格有序的数据； HTTP/1.1、HTTP/2 等基于 TCP 的协议； 长连接通信。 文件下载需要保证最终文件完整，因此通常需要可靠传输。\nUDP 本身没有拥塞控制和可靠重传。如果使用 UDP 传输文件，应用层就需要自己实现：\n丢包检测； 重传； 顺序恢复； 流量控制； 拥塞控制。 TCP 已经提供了这些能力，因此使用 TCP 开发更加直接。\n9. 短通信与长连接 DNS 是典型的短请求、短响应场景：\n发送一次查询 ↓ 服务端返回查询结果 ↓ 本次通信结束 UDP 不需要建立连接，适合这种简单的请求响应。\n“UDP 做短连接”更准确的说法是：\nUDP 没有 TCP 意义上的连接，适合短暂的一次性请求响应。\n有些业务也会选择 TCP 完成短请求，因为：\nTCP 相关框架成熟； 开发更加方便； 不需要自己实现可靠性； 业务本身更重视开发成本和正确性。 TCP 则天然适合需要长期保持通信状态的长连接，但 TCP 并不是只能做长连接。\n十二、阶段总结 从传统阻塞 I/O、epoll 再到 io_uring，容易产生一种混沌感。\n当前最需要捋清的是三个层次之间的关系：\nTCP 提供字节流、连接、可靠传输等协议能力 ↓ epoll / io_uring 解决应用程序如何高效处理 I/O ↓ 业务协议 解决消息边界、心跳、会话和错误处理 本轮实验可以得到三个明确认识：\nepoll 应当与非阻塞 I/O 配合，否则单个连接可能阻塞整个事件循环。 当前测试中，io_uring 的 QPS 全面高于非阻塞 epoll，整体优势约为 17%～21%。 客户端的连接模型、收发方式和计数准确性，会直接影响测试结果。当前 -c 参数没有生效，循环收发也尚未实现。 十三、面试复盘 面试需要不断复盘。\n简历中能够被问到的内容是有限的，每次面试后都应该记录问题，并重新整理问题背后的知识。\n原笔记中的经验数据为：\n面试时间 个人估计的通过概率 30 分钟 5% 45 分钟 30% 60 分钟 80% 求职过程可以通过两个比例进行观察：\n邀面数量 / 投递数量 → 反映简历的匹配度和成功度 Offer 数量 / 邀面数量 → 反映技术能力和面试表现 ","date":"2026-08-20T00:00:00+08:00","image":"/MyBlog/p/io-uring-qps-vs-epoll/cover.svg","permalink":"/MyBlog/p/io-uring-qps-vs-epoll/","title":"io_uring（二）：QPS 实测，真的比 epoll 强吗？"},{"content":"一、网络编程要解决什么问题 在 C/S 模式中，服务端通常先执行：\nsocket -\u0026gt; bind -\u0026gt; listen 客户端也会先创建 socket。各种网络模型前面的准备过程基本相同，主要区别在后续如何处理连接和 I/O。\n网络编程中有四件无法预先确定的事情：\n客户端什么时候建立连接； 客户端什么时候断开连接； 客户端什么时候发送数据； 数据什么时候能够写入发送缓冲区。 因此，网络编程主要解决四类问题：\n连接的建立； 连接的断开； 数据的接收； 数据的发送。 每个 socket 都对应接收缓冲区（receive buffer）和发送缓冲区（send buffer）。write 只负责把数据从用户态复制到内核态的发送缓冲区；数据什么时候到达对端、如何到达对端，属于网络协议栈要解决的问题。\n常见的网络模型包括：\n阻塞 I/O 模型； Reactor； Proactor（IOCP）。 二、阻塞 I/O 与 Reactor 一次 I/O 可以分成两个阶段：\nI/O 检测：判断 I/O 是否就绪； I/O 操作：真正执行 accept、read、write、connect 等操作。 1. 阻塞 I/O 阻塞 I/O 通过阻塞当前线程来等待 I/O 就绪，从而解决“不知道事件什么时候发生”的问题。\n阻塞和非阻塞主要描述 I/O 检测阶段：\n阻塞：I/O 没有就绪时，线程会等待； 非阻塞：I/O 没有就绪时，函数立即返回。 2. Reactor Reactor 使用：\nI/O 多路复用（检测 I/O）+ 非阻塞 I/O（操作 I/O） 它先注册事件。当事件触发时，系统发出就绪通知，用户层收到通知后，再调用 POSIX I/O 函数完成操作。因此，Reactor 属于：\n同步 I/O + 异步事件通知 select、poll、epoll 用于同时检测多路 I/O 是否就绪，它们只负责 I/O 检测；Reactor 则包含 I/O 检测和后续的 I/O 操作。\n所以它们的关系是：\nReactor = select/poll/epoll 等 I/O 多路复用 + 非阻塞 I/O 操作 三、IOCP 与 Reactor 的区别 IOCP（I/O Completion Port，I/O 完成端口）是 Windows 提供的一种高效异步 I/O 机制。\nReactor 的过程：\n注册事件 ↓ 内核检测到 I/O 就绪 ↓ 用户层收到就绪通知 ↓ 用户层调用 accept/read/write 等函数完成 I/O 这是一种就绪通知。\nIOCP 的过程：\n用户层投递异步 I/O 请求 ↓ 内核检测并完成 I/O ↓ 用户层收到完成通知 因此：\nReactor：同步 I/O、异步事件； IOCP：异步 I/O、异步事件。 两者都是为了解决网络 I/O 问题，因此 IOCP 在整体概念上更接近 Reactor；但 IOCP 并不是 select、poll、epoll 这样的 I/O 多路复用机制。\n在 Reactor 中，即使 socket 已经就绪，真正的数据复制仍由用户线程调用 I/O 函数完成。IOCP 则由用户层发起异步操作，由内核完成 I/O；在此期间，用户线程可以处理其他任务。\n四、IOCP 的工作原理 网络 I/O 属于外设资源，相关数据需要通过系统调用，由内核进行操作。\nIOCP 的基本流程如下：\n使用 CreateIoCompletionPort 创建完成端口； 将 socket 与 IOCP 绑定； 投递异步 I/O 操作； 内核进行 I/O 检测和 I/O 操作； 操作完成后，内核把完成信息放入完成队列； 用户层通过 GetQueuedCompletionStatus 取得完成通知。 常见的异步操作包括：\nAcceptEx：接受连接； ConnectEx：主动建立连接； DisconnectEx：主动断开连接； WSARecv：接收数据； WSASend：发送数据。 例如建立连接时：\nsocket 绑定 IOCP ↓ 投递 AcceptEx ↓ 内核完成连接接收 ↓ GetQueuedCompletionStatus 取得完成通知 与传统 accept 不同，AcceptEx 是预先投递的异步请求。服务器可以在启动时投递多个 AcceptEx，从而并发接收多条连接。\nIOCP 与线程池 IOCP 通常会绑定一个线程池。\n当线程调用 GetQueuedCompletionStatus 时，该线程会与 IOCP 产生关联。线程退出或关闭后，关联取消；一个线程只能关联一个 IOCP。\nGetQueuedCompletionStatus 是阻塞接口：\n等待完成队列出现数据 ↓ 取得完成状态 ↓ 在用户态处理完成事件 最后可以使用 CloseHandle 关闭 I/O 完成端口句柄。\n五、完成事件如何与连接关联 收到完成通知后，需要知道是哪一条连接、哪一次操作完成了。\n1. Reactor 中的关联方式 以 epoll 为例，注册事件时可以把一个值交给内核保存：\nev.data.fd = fd; 也可以关联用户态指针：\nev.data.ptr = user_data; epoll_wait 返回事件时会带回这个值，因此可以判断是哪一条连接发生了事件。\n2. IOCP 中的关联方式 IOCP 有两种关联信息。\n第一种是 CreateIoCompletionPort 的第三个参数 CompletionKey。它可以保存一个用户态指针，并在其中记录 socket 等连接信息。\n第二种是异步函数最后传入的 OVERLAPPED 指针。可以把 OVERLAPPED 放进自定义结构体：\nstruct OverlappedPerIO { OVERLAPPED overlapped; SOCKET socket; // 其他与本次 I/O 相关的数据 }; 内核使用其中的 OVERLAPPED，用户层则可以在结构体中附加需要关联的数据。\n可以这样理解：\nCompletionKey：关联连接级信息； OVERLAPPED*：关联某一次 I/O 操作的信息。 六、重叠 I/O 异步 I/O 是投递请求后无需原地等待，可以由其他线程等待完成通知。\n重叠 I/O 是指：无需等待上一个 I/O 完成，就可以继续投递下一个操作，让多个操作同时处于进行状态。\n例如，如果每次只投递一个 AcceptEx，完成一次后再投递下一次，那么客户端较多时，建立连接的效率会比较低。服务器可以在启动时投递多个 AcceptEx：\nAcceptEx 请求 1 ─┐ AcceptEx 请求 2 ─┼─→ 同时等待多个客户端连接 AcceptEx 请求 3 ─┘ 每个重叠 I/O 都使用一个独立的 OVERLAPPED 结构。操作完成后，GetQueuedCompletionStatus 会返回对应的 OVERLAPPED*，用户层据此找到这次操作的上下文。\n七、连接断开的判断 客户端主动断开连接时，可以通过以下方式判断。\nReactor epoll_wait 返回 EPOLLHUP 或 EPOLLRDHUP； read / recv 返回 0。 IOCP GetLastError 返回相关错误，例如异常断开时的 ERROR_NETNAME_DELETED； GetQueuedCompletionStatus 返回的完成字节数 dwBytes == 0。 超时情况下还可能出现 WAIT_TIMEOUT。\n八、投递操作 AcceptEx 可以多次投递，以并发接收连接； WSARecv 涉及接收顺序和线程安全问题； WSASend 可以多次投递。 ","date":"2026-08-20T00:00:00+08:00","image":"/MyBlog/p/iocp-windows-async-io/cover.svg","permalink":"/MyBlog/p/iocp-windows-async-io/","title":"IOCP：Windows 异步机制"},{"content":" 核心目标：能从代码上说清楚 epoll 和 io_uring 的区别，而不是只记 API。\n1. 今天最重要的结论 一句话区分：\nReactor / epoll：内核通知“现在可以做 I/O 了”，真正的 accept/recv/send 由应用执行。 Proactor / io_uring：应用先提交 I/O 操作，内核完成后通知“这个操作已经做完了”。 最值得记住的一组对照：\nepoll 的 EPOLLIN = 可以读了，但数据还没被应用读出 io_uring 的 READ CQE = 读操作已结束，结果在 cqe-\u0026gt;res，数据已进入指定 buffer epoll 的 EPOLLOUT = 现在可以写，但应用还要调用 send/write io_uring 的 WRITE CQE = 写操作已结束，cqe-\u0026gt;res 表示实际写出的字节数 因此，二者虽然都有“注册/提交 → 等待 → 处理事件”的循环，但事件的含义完全不同：\nepoll 返回的是就绪事件。 io_uring 返回的是完成事件。 2. “异步”与“异步 I/O”不是一回事 2.1 一般意义上的异步 发起任务后，不原地等待它结束，当前执行流可以继续做别的事；任务完成后再通过回调、消息、事件或协程恢复等方式取得结果。\n例如：把日志写盘任务放入线程池。业务线程只投递任务，工作线程实际调用同步 write()。从业务流程看它是异步的，但底层 write() 本身仍可能是同步 I/O。\n2.2 同步 I/O 普通的：\nread(fd, buffer, length); write(fd, buffer, length); recv(fd, buffer, length, 0); send(fd, buffer, length, 0); 一次函数调用既发起操作，也返回该次操作的结果。即使 fd 被设为非阻塞，调用者仍然要亲自执行 recv/send；“非阻塞”不自动等于“异步 I/O”。\n2.3 epoll 做到的是什么 epoll_wait() 可以高效等待大量 fd 的就绪状态，避免逐个 fd 轮询。它通常与非阻塞 socket 配合，构成 Reactor。\n但 epoll 不替应用搬运数据：收到 EPOLLIN 后，代码仍要调用 recv()；收到 EPOLLOUT 后，仍要调用 send()。\n2.4 io_uring 做到的是什么 应用把“请替我执行 accept/read/write……”描述成 SQE，提交给内核；操作完成后，内核写入 CQE。应用处理 CQE 时，看到的是操作结果，而不只是“可以开始操作”。\n3. io_uring 的两个环 应用准备 SQE → SQ（Submission Queue）→ 内核执行 I/O ↓ 应用处理结果 ← CQ（Completion Queue）← 内核生成 CQE SQ / SQE SQ：提交队列。 SQE：一次待执行 I/O 的描述，例如操作类型、fd、buffer、长度和用户数据。 io_uring_get_sqe()：取得一个可填写的 SQE。 io_uring_prep_accept/read/write(...)：只是在填写 SQE，不会立刻执行 I/O。 io_uring_submit()：把准备好的请求提交给内核。 CQ / CQE CQ：完成队列。 CQE：一个已经完成的 I/O 结果。 cqe-\u0026gt;res：操作结果。成功时通常是新 fd 或字节数；失败时是负的 errno 值。 cqe-\u0026gt;user_data：应用提交时附带的标识，用来判断这是哪个连接、哪类操作。 io_uring_cq_advance()：告诉 ring，这批 CQE 已处理，可以回收。 为什么使用环形队列和 mmap 环形队列可以复用固定槽位，减少频繁分配；SQ/CQ 通过 mmap 在用户态和内核态之间共享队列元数据，减少提交、取结果时的额外系统调用和元数据复制。\n但要注意：这不代表所有 I/O 数据都天然“零拷贝”。例如普通 socket 接收的数据通常仍需进入应用提供的 buffer。\n4. io_uring 三个底层系统调用 io_uring_setup：创建并配置 ring，建立 SQ/CQ 所需资源。 io_uring_enter：提交请求和/或等待完成事件。 io_uring_register：预注册文件、buffer、事件等资源，减少热路径开销；它不是每个普通请求都必须调用。 课堂代码使用的是 liburing 封装：\nio_uring_queue_init_params(ENTRIES_LENGTH, \u0026amp;ring, \u0026amp;params); io_uring_get_sqe(\u0026amp;ring); io_uring_prep_accept(...); io_uring_submit(\u0026amp;ring); io_uring_wait_cqe(\u0026amp;ring, \u0026amp;cqe); 可近似理解为：初始化 ring → 取得请求槽位 → 描述操作 → 提交 → 等结果。\n5. 你的 io_uring Echo Server 怎么走 关键入口：\nset_event_accept(\u0026amp;ring,sockfd,(struct sockaddr*)\u0026amp;clientaddr,\u0026amp;len,0); set_event_accept() 做两件事：\n用 io_uring_get_sqe() 取得 SQE。 用 io_uring_prep_accept() 填写 accept 请求，并在 user_data 中记录 fd + EVENT_ACCEPT。 此时只是准备请求，并没有等待客户端。\n主循环：\nio_uring_submit ↓ 提交当前准备好的 accept/recv/send 请求 io_uring_wait_cqe ↓ 没有完成结果时在这里等待 io_uring_peek_batch_cqe ↓ 批量取得已完成操作 根据 user_data 判断 EVENT_ACCEPT ↓ 再次 set_event_accept，同时为新 clientfd 提交 recv ↓ io_uring_cq_advance 你测试时发现程序阻塞在 io_uring_wait_cqe()，这是合理的：\nio_uring_prep_accept() 只准备请求，很快返回。 io_uring_submit() 提交请求。 在客户端真正连接前，accept 尚未完成，CQ 中没有结果，所以 wait_cqe() 等待。 客户端连接后，CQE 到达，cqe-\u0026gt;res 应是新连接的 client fd（失败则为负数）。 5.1 三种事件不是“就绪事件”，而是操作身份 #define EVENT_ACCEPT 0 #define EVENT_READ 1 #define EVENT_WRITE 2 这里的 EVENT_* 是应用自己定义的标签。准备 SQE 时，代码把 fd + event 写入 sqe-\u0026gt;user_data；操作完成后，再从 CQE 的 user_data 还原标签，据此判断完成的是 accept、recv 还是 send。\n三种 SQE 的含义：\n函数 提交给内核的操作 CQE 到达时 entries-\u0026gt;res 的含义 set_event_accept 接收一个新连接 新连接 fd；负数表示失败 set_event_recv 把数据读进指定 buffer 实际读取字节数；0 表示对端关闭；负数表示失败 set_event_send 发送指定 buffer 中的数据 实际发送字节数；负数表示失败 5.2 完整状态循环 启动：准备 ACCEPT SQE ↓ submit ACCEPT 完成 ├─ entries-\u0026gt;res 得到 connfd ├─ 再准备一个 ACCEPT，继续接收其他客户端 └─ 为 connfd 准备 RECV ↓ submit READ 完成 ├─ res == 0：对端断开，close(fd) └─ res \u0026gt; 0：buffer 中已有数据，准备 SEND ↓ submit WRITE 完成 ↓ 再为该 fd 准备 RECV 对单个连接而言，核心循环就是：\nRECV 完成 → SEND 完成 → 再次 RECV 这份代码没有额外解析数据，而是把收到的 buffer 原样发送回去，因此它是一个 io_uring Echo Server。\n5.3 为什么循环开头的 submit 能提交刚刚准备的操作 在处理 CQE 时调用的 set_event_accept/recv/send() 都只是取得并填写新的 SQE。它们不会立刻执行；本轮处理结束并 cq_advance 后，程序回到 while 开头，由下一次 io_uring_submit() 统一提交。\n因此时间顺序是：\n本轮处理 CQE → 准备下一批 SQE → advance → 下一轮 submit 这种批量准备、批量提交正是 io_uring 的重要思路。\n为什么处理后要再次设置 accept 普通的 io_uring_prep_accept() 描述的是一次 accept 操作。完成一次就消费掉一次请求；若想继续接收连接，需要再准备并提交新的 accept。\n这与 epoll 的常规 LT 模式不同：监听 fd 注册 EPOLLIN 后，只要仍处于可读状态，epoll 可以继续报告就绪，无需每完成一次 accept 就重新 EPOLL_CTL_ADD。\n为什么必须 advance CQE 被读取不等于被消费：\nio_uring_cq_advance(\u0026amp;ring,nready); 这一步更新 CQ 的消费位置。漏掉它，旧 CQE 会一直被当成尚未处理，表现为循环反复看到同一结果。\n6. 你的 Reactor 代码怎么走 6.1 初始化 epoll_create ↓ 创建 20 个监听 socket（端口 2000～2019） ↓ 把监听 fd 以 EPOLLIN 加入 epoll ↓ 进入 epoll_wait 主循环 监听 fd 的回调被设置为 accept_cb：\nconn_list[sockfd].r_action.recv_callback = accept_cb; 6.2 建立连接 监听 fd 出现 EPOLLIN ↓ 主循环调用 accept_cb ↓ accept_cb 主动执行 accept() ↓ event_register(clientfd, EPOLLIN) 关键点：EPOLLIN 只表示监听 socket 可以执行 accept；真正建立并取出 client fd 的动作仍由 accept() 完成。\n6.3 接收数据 clientfd 出现 EPOLLIN ↓ 调用 recv_cb ↓ recv() 把数据读进 rbuffer ↓ ws_request() 处理请求 ↓ 把关注事件修改为 EPOLLOUT 如果 recv() 返回 0，说明对端正常断开；返回负数则表示错误。当前代码随后关闭 fd 并从 epoll 删除。\n6.4 发送数据 clientfd 出现 EPOLLOUT ↓ 调用 send_cb ↓ ws_response() 生成响应 ↓ send() 主动发送 wbuffer ↓ 把关注事件改回 EPOLLIN 整个连接状态近似为：\n等待读 EPOLLIN → recv + 处理请求 → 等待写 EPOLLOUT ↑ ↓ └──────── send + 改回读 ──────────┘ 7. 逐段对照：Reactor 与 io_uring 阶段 epoll / Reactor io_uring / Proactor 思路 接收连接 注册监听 fd 的 EPOLLIN 提交一个 accept SQE 通知到达 “监听 fd 可以 accept” “accept 已完成” 新连接 fd 应用调用 accept() 得到 从 accept CQE 的 res 得到 接收数据 EPOLLIN 后应用调用 recv() 预先提交 recv/read SQE，完成后 buffer 已有数据 发送数据 EPOLLOUT 后应用调用 send() 预先提交 send/write SQE，完成后得到实际写入量 事件身份 events[i].data.fd + 回调表 cqe-\u0026gt;user_data 标识 fd/事件/上下文 一次操作结束 修改下一次关注的就绪事件 消费 CQE，再准备下一次 I/O SQE 批量处理 epoll_wait(..., events, 1024, ...) io_uring_peek_batch_cqe(..., cqes, 128) 代码上的一一对应：\nepoll_ctl ADD/MOD ↔ io_uring_get_sqe + prep_xxx epoll_wait ↔ io_uring_wait_cqe / peek_batch_cqe events[i].data.fd ↔ cqe-\u0026gt;user_data EPOLLIN 后 recv ↔ READ CQE 到达时数据已读完 EPOLLOUT 后 send ↔ WRITE CQE 到达时写操作已完成 下一轮 set_event ↔ 下一轮重新准备 SQE ↔ io_uring_cq_advance 消费完成项 8. 对学习笔记中三个说法的校正 ① “SQE 与 CQE 是同一个节点，共用一块内存”——不准确 SQE 和 CQE 是不同队列中的不同结构：SQE 描述请求，CQE 描述结果。它们通过 user_data 关联。共享内存指的是这些 ring 能被用户态与内核态共同访问，不是 SQE 原地变成 CQE。\n② “io_uring_prep_accept 底层调用 register”——不准确 io_uring_prep_accept() 主要是填写 SQE。io_uring_register 是独立的资源注册系统调用，普通 accept 请求不要求每次 register。\n③ “mmap 后就没有 copy”——范围过大 mmap 主要减少 SQ/CQ 控制信息在用户态和内核态之间的复制及系统调用开销；普通网络 I/O 的 payload 是否复制，是另一层问题。\n9. 最小记忆框架 只背下面四句话：\nepoll 给我的是就绪，我还要自己 accept/recv/send。 io_uring 给我的是完成，结果看 cqe-\u0026gt;res，身份看 cqe-\u0026gt;user_data。 io_uring 的路径是：取 SQE → prep → submit → 等 CQE → 处理 → advance。 普通 io_uring 请求通常是一次性的：完成一次，消费一次，再提交下一次。 ","date":"2026-08-19T00:00:00+08:00","image":"/MyBlog/p/io-uring-vs-epoll/cover.svg","permalink":"/MyBlog/p/io-uring-vs-epoll/","title":"io_uring（一）：与 epoll 一较高下"},{"content":" 本篇只围绕一个问题展开：用户态 TCP 协议栈怎样让一个应用线程并发管理多条 TCP 连接？\n前三篇已经反复讲过 DPDK 收发、mbuf、Ethernet/IPv4/TCP 解析、三次握手、SEQ/ACK、TCP 状态机和 POSIX Socket。本篇只在它们影响 epoll 逻辑时简要回顾，不再重新逐层讲包头和握手细节。\n一、先给出整篇的核心答案 TCP 并发 epoll 的实现可以浓缩成一个闭环：\n1. 每条 TCP 连接拥有独立的 stream/TCB 和用户态 fd 2. 应用通过 epoll_ctl 注册“我关心哪些 fd 的哪些事件” 3. 协议栈处理报文时发现某个 fd 已经可读或可写 4. 协议栈调用 epoll 回调，把该 fd 放入就绪队列 5. 回调唤醒阻塞在 epoll_wait 的应用线程 6. epoll_wait 返回就绪 fd，应用执行 accept/recv/send/close 7. 应用重新进入 epoll_wait，继续管理所有连接 epoll 没有让 TCP 报文并行到达，也没有替协议栈处理握手。它解决的是：\n当许多连接都可能产生数据时，应用怎样只处理当前已经就绪的连接，而不是为每条连接创建一个长期阻塞的线程。\n理解本篇时始终抓住三种对象：\n对象 解决的问题 ng_tcp_stream 这是谁的连接，当前 TCP 状态和数据是什么 epitem 应用是否关注这个 fd，它现在是否就绪 eventpoll 一个 epoll 实例管理的整集、就绪集和等待线程 二、只保留必要的旧知识：数据最终从哪里来到哪里去 完整程序有三个长期运行的执行流：\nmain/lcore：网卡 RX/TX ↕ 全局 in/out ring pkt_process/lcore：Ethernet/IP/TCP 解析、TCP 输出封包 ↕ 每连接 rcvbuf/sndbuf tcp_server_entry/lcore：accept、recv、send、epoll_wait 两层 ring 不要混淆：\n全局 ring-\u0026gt;in/out 传递 rte_mbuf *，用于网卡线程与协议处理线程之间传包。 每个 ng_tcp_stream 的 rcvbuf/sndbuf 传递 TCP fragment，用于协议栈与应用之间传数据。 一段客户端数据的最短路径是：\n网卡 RX → 全局 in ring → ng_tcp_process() → 按四元组找到 stream → payload 放入 stream-\u0026gt;rcvbuf → 触发 EPOLLIN → nepoll_wait() 返回 connfd → 应用 nrecv(connfd) 应用响应的路径相反：\nnsend(connfd) → fragment 放入 stream-\u0026gt;sndbuf → ng_tcp_out() 取出并封装 TCP 报文 → 全局 out ring → 网卡 TX 这些收发与报文构造细节在前几篇已有完整说明。本篇真正关心的是中间这一步：\nstream 的状态发生变化 → 怎样让正在等待的应用知道？ 三、支持并发的第一前提：连接必须彼此独立 1. 一条连接对应一个 ng_tcp_stream struct ng_tcp_stream { int fd; uint32_t sip, dip; uint16_t sport, dport; uint8_t protocol; uint32_t snd_nxt; uint32_t rcv_nxt; NG_TCP_STATUS status; struct rte_ring *sndbuf; struct rte_ring *rcvbuf; pthread_cond_t cond; pthread_mutex_t mutex; }; 它是本代码中的简化 TCB。每个客户端都必须拥有独立的：\n地址与端口； TCP 状态； SEQ/ACK； 接收和发送缓冲区； 用户态 fd； 阻塞与唤醒状态。 因此，TCP 并发的根基不是 epoll，而是连接级状态隔离。如果两条连接共用序列号或接收缓冲区，换成 epoll 也不能让协议栈正确并发。\n2. 报文怎样找到自己的 stream if (iter-\u0026gt;sip == sip \u0026amp;\u0026amp; iter-\u0026gt;dip == dip \u0026amp;\u0026amp; iter-\u0026gt;sport == sport \u0026amp;\u0026amp; iter-\u0026gt;dport == dport) { return iter; } 一般网络流用五元组标识；这里已经确定是 TCP，所以搜索时比较四元组。两个客户端连接同一个服务端地址和端口时，客户端 IP 或源端口不同，因此能找到不同的 stream。\n3. 监听 stream 和连接 stream 服务端只有一个监听 stream，但可以有许多已连接 stream：\nlistenfd / LISTEN stream ├── connfd A / ESTABLISHED stream A ├── connfd B / ESTABLISHED stream B └── connfd C / ESTABLISHED stream C 收到 SYN 时，协议栈保留监听 stream，并创建一个新的连接 stream 处理该客户端：\nstruct ng_tcp_stream *syn = ng_tcp_stream_create( iphdr-\u0026gt;src_addr, iphdr-\u0026gt;dst_addr, tcphdr-\u0026gt;src_port, tcphdr-\u0026gt;dst_port); LL_ADD(syn, table-\u0026gt;tcb_set); syn-\u0026gt;status = NG_TCP_STATUS_SYN_RCVD; 监听对象负责“还能不能接新连接”，连接对象负责“这个客户端的数据和状态”。epoll 后面会分别监听这两类 fd。\n四、一连接一线程为什么能工作，又为什么需要 epoll 1. 一连接一线程 最直观的并发方式是：\n主线程：不断 naccept() 连接 A：线程 A 阻塞在 nrecv(A) 连接 B：线程 B 阻塞在 nrecv(B) 连接 C：线程 C 阻塞在 nrecv(C) 它适合验证连接表、stream 隔离和收发缓冲区是否支持多个客户端。问题是大部分连接经常没有数据，线程却仍占用栈空间和调度资源。连接数增长时，线程数也同步增长。\n2. epoll 的改变 epoll 模型下，应用线程不阻塞在某一个 nrecv(connfd) 上，而是阻塞在“所有已注册 fd 的就绪集合”上：\n应用线程：nepoll_wait(A, B, C, ...) 只有 B 有数据 → nepoll_wait 只返回 B → 应用处理 B → 再次等待所有连接 所以两种模型的区别不是 TCP 连接数，而是等待位置：\n一连接一线程：每个线程等待一个 fd epoll：一个线程等待一组 fd 五、为什么不能直接调用 Linux 原生 epoll 代码中的 listenfd、connfd 和 epfd 由自己的位图分配：\nstatic int get_fd_frombitmap(void) { for (int fd = DEFAULT_FD_NUM; fd \u0026lt; MAX_FD_COUNT; fd++) { if (fd_table 中该位空闲) { 标记为已用; return fd; } } return -1; } 这些整数只在用户态协议栈内部有意义：\n用户态 fd → get_hostinfo_fromfd(fd) → ng_tcp_stream / localhost / eventpoll Linux 内核没有创建对应的 socket 对象，也不知道 stream-\u0026gt;rcvbuf 是否有数据。因此把这种 connfd 传给内核 epoll_ctl()，内核无法监听它。\n既然 Socket API 是用户态自己实现的，事件机制也要自己补齐：\nnsocket / nbind / nlisten / naccept / nrecv / nsend + nepoll_create / nepoll_ctl / nepoll_wait 六、理解 epoll 的关键：就绪是一种状态 应用关注的不是“是否来过一个包”，而是“现在执行某个操作会不会阻塞”。\n1. 监听 fd 可读 监听 fd 的 EPOLLIN 表示：\n至少有一条已完成握手的连接可以被 naccept() 取出 它不是说监听 socket 收到了应用 payload，而是说 accept 现在不会阻塞。\n2. 连接 fd 可读 连接 fd 的 EPOLLIN 表示：\nnrecv() 现在可以取得 payload，或者取得 EOF/关闭信息 因此，收到 FIN 也可以触发 EPOLLIN。应用随后调用 nrecv() 得到 0，知道对端已关闭。\n3. 连接 fd 可写 EPOLLOUT 的一般含义是：发送缓冲区有空间，send 不会因为缓冲区已满而阻塞。它不等于“一个数据包已经从网卡发完”。\n本节代码重点实现的是 EPOLLIN 链路；要完整支持 EPOLLOUT，还需要定义 sndbuf 从满变为可用时怎样产生通知。\n这一点非常重要：\nepoll 事件是 Socket 操作条件的变化，不是对每个网络包做一次简单转发。\n七、epoll 的两个集合 一个 epoll 实例内部有两份不同用途的集合。\n1. 整集：我关注谁 整集保存所有通过 EPOLL_CTL_ADD 注册的 fd。代码用红黑树组织，以 sockfd 为 key：\n整集 = {listenfd, connfd_A, connfd_B, connfd_C, ...} 它回答：\n这个 fd 是否被注册？ 应用对它关心 EPOLLIN 还是 EPOLLOUT？ ADD/DEL/MOD 应该操作哪个节点？ 红黑树提供稳定的 O(log n) 查找、插入和删除，并能随注册数量按节点增长。哈希的平均查找更快，但要额外处理桶容量、冲突和扩容。这里选择红黑树的重点是动态集合与稳定性能，并不是红黑树在所有场景都绝对更快。\n2. 就绪集：现在谁能处理 就绪集是链表，只保存当前已经产生事件的节点：\n整集：{listenfd, A, B, C, D} 就绪集：{B, D} nepoll_wait() 无须扫描整棵红黑树，只需从就绪链表取出 B、D。\n3. 同一个节点同时属于两份集合 struct epitem { RB_ENTRY(epitem) rbn; LIST_ENTRY(epitem) rdlink; int rdy; int sockfd; struct epoll_event event; }; 一个 epitem 中同时有红黑树节点和链表节点：\nrbn：长期挂在整集，表示注册关系 rdlink：就绪时临时挂在就绪链表 rdy：防止同一节点被重复插入就绪链表 节点进入就绪链表后仍保留在红黑树里。应用消费一次就绪事件，不等于取消对这个 fd 的关注；只有 EPOLL_CTL_DEL 才删除注册关系。\n4. eventpoll 管理整个 epoll 实例 struct eventpoll { int fd; ep_rb_tree rbr; int rbcnt; LIST_HEAD(, epitem) rdlist; int rdnum; pthread_mutex_t mtx; pthread_spinlock_t lock; pthread_cond_t cond; pthread_mutex_t cdmtx; }; 关系可以画成：\nepfd └── eventpoll ├── rbr：全部已注册 epitem ├── rdlist：当前就绪 epitem └── cond：没有就绪事件时，让 nepoll_wait 睡眠 八、三个外部接口怎样组成应用侧逻辑 1. nepoll_create()：创建事件管理器 它做的事情不是创建 TCP 连接，而是创建一个 eventpoll：\n分配 epfd → 分配 eventpoll → 初始化红黑树 → 初始化就绪链表 → 初始化锁与条件变量 → 建立 epfd 到 eventpoll 的映射 应用以后通过 epfd 找到这一整套事件管理状态。\n2. nepoll_ctl()：维护整集 ADD：树中不存在 → 分配 epitem → 插入红黑树 DEL：树中存在 → 从红黑树移除 → 释放 epitem MOD：树中存在 → 修改关注的事件 因此，nepoll_ctl() 只是在表达应用的兴趣：\n“如果这个 fd 以后出现我关心的状态，请通知我。”\n注册本身不会凭空制造事件。\n3. nepoll_wait()：没有事件就睡，有事件就返回 while (ep-\u0026gt;rdnum == 0 \u0026amp;\u0026amp; timeout != 0) { if (timeout \u0026gt; 0) pthread_cond_timedwait(\u0026amp;ep-\u0026gt;cond, \u0026amp;ep-\u0026gt;cdmtx, ...); else if (timeout \u0026lt; 0) pthread_cond_wait(\u0026amp;ep-\u0026gt;cond, \u0026amp;ep-\u0026gt;cdmtx); } 超时语义：\ntimeout \u0026lt; 0：一直等待； timeout == 0：立即检查并返回； timeout \u0026gt; 0：最多等待指定时长。 醒来后，它从就绪链表取出最多 maxevents 个节点，把其中的 epoll_event 复制给应用：\nrdlist 中的 epitem → events[0...n-1] → 返回 nready 至此，epoll 只告诉应用“哪些 fd 可以处理”。真正的 accept/recv/send 仍由应用自己调用。\n九、最关键的内部接口：协议栈怎样触发事件 三个 nepoll_* 接口面向应用；epoll_event_callback() 面向协议栈：\nint epoll_event_callback(struct eventpoll *ep, int sockid, uint32_t event) { struct epitem *epi = RB_FIND(..., sockid); if (!epi) return -1; if (epi-\u0026gt;rdy) { epi-\u0026gt;event.events |= event; return 1; } pthread_spin_lock(\u0026amp;ep-\u0026gt;lock); epi-\u0026gt;rdy = 1; LIST_INSERT_HEAD(\u0026amp;ep-\u0026gt;rdlist, epi, rdlink); ep-\u0026gt;rdnum++; pthread_spin_unlock(\u0026amp;ep-\u0026gt;lock); pthread_mutex_lock(\u0026amp;ep-\u0026gt;cdmtx); pthread_cond_signal(\u0026amp;ep-\u0026gt;cond); pthread_mutex_unlock(\u0026amp;ep-\u0026gt;cdmtx); } 它完成四步：\n按 sockid 查整集 → 把事件记录到对应 epitem → 把 epitem 加入就绪链表 → signal 唤醒 nepoll_wait 如果节点已经在就绪链表中，就不重复插入，只合并事件位：\nepi-\u0026gt;event.events |= event; 这避免同一个 fd 连续收到多个通知时，在 rdlist 中出现多个相同节点。\nepoll 模块在整体架构中的位置 协议栈 epoll 应用 握手完成 ─┐ 收到数据 ─┼→ event_callback → rdlist → cond_signal → nepoll_wait 收到 FIN ─┘ ↓ accept/recv/close 所以 epoll 不是协议栈之外的独立轮询器。它必须由协议栈在状态真正改变的地方主动通知。\n十、用三条时间线彻底串起实现 时间线一：新客户端完成连接 ① 客户端发送 SYN ② ng_tcp_process 找到 LISTEN stream ③ 为客户端创建新的 stream，进入 SYN_RCVD ④ 协议栈发送 SYN+ACK ⑤ 收到客户端 ACK，新 stream 进入 ESTABLISHED ⑥ 协议栈触发 listenfd 的 EPOLLIN ⑦ nepoll_wait 返回 listenfd ⑧ 应用调用 naccept(listenfd) ⑨ naccept 取出已建立 stream，为它分配 connfd ⑩ 应用用 EPOLL_CTL_ADD 把 connfd 加入 epoll 对应代码的关键通知：\nstream-\u0026gt;status = NG_TCP_STATUS_ESTABLISHED; struct ng_tcp_stream *listener = ng_tcp_stream_search(0, 0, 0, stream-\u0026gt;dport); epoll_event_callback(table-\u0026gt;ep, listener-\u0026gt;fd, EPOLLIN); 为什么通知 listener-\u0026gt;fd，而不是新连接的 fd？\n因为此时应用还没有执行 naccept()，新 stream 还没有分配给应用使用的 connfd。对应用来说，当前可执行的动作是 accept(listenfd)，所以就绪的是监听 fd。\n时间线二：已连接客户端发送 payload ① 报文按四元组找到 established stream ② 协议栈复制 payload，构造 fragment ③ fragment 进入 stream-\u0026gt;rcvbuf ④ 协议栈触发 stream-\u0026gt;fd 的 EPOLLIN ⑤ callback 找到 connfd 对应的 epitem ⑥ epitem 进入 rdlist，唤醒 nepoll_wait ⑦ 应用得到 connfd ⑧ 应用调用 nrecv(connfd)，从该 stream 的 rcvbuf 取数据 关键顺序必须是：\n先让数据真正可读 → 再发布 EPOLLIN 否则应用被唤醒后可能发现 rcvbuf 仍为空。\n时间线三：客户端发送 FIN ① 协议栈收到 FIN ② stream 进入 CLOSE_WAIT ③ rcvbuf 中放入长度为 0 的 fragment，表示 EOF ④ 触发 connfd 的 EPOLLIN ⑤ nepoll_wait 返回 connfd ⑥ nrecv(connfd) 返回 0 ⑦ 应用 EPOLL_CTL_DEL，然后 nclose(connfd) 这条时间线解释了为什么“关闭”也能通过可读事件交给应用：读取操作已经不会阻塞，只是结果为 EOF。\n十一、epoll 服务器循环现在应该怎样读 int epfd = nepoll_create(1); ev.events = EPOLLIN; ev.data.fd = listenfd; nepoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, \u0026amp;ev); while (1) { int nready = nepoll_wait(epfd, events, 128, 5); for (int i = 0; i \u0026lt; nready; i++) { if (events[i].data.fd == listenfd) { int connfd = naccept(listenfd, ...); ev.events = EPOLLIN; ev.data.fd = connfd; nepoll_ctl(epfd, EPOLL_CTL_ADD, connfd, \u0026amp;ev); } else { int connfd = events[i].data.fd; int n = nrecv(connfd, buff, BUFFER_SIZE, 0); if (n \u0026gt; 0) { nsend(connfd, buff, n, 0); } else { nepoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL); nclose(connfd); } } } } 不要只把它看成一个 API 调用顺序。这个循环其实在处理两类状态机入口：\nlistenfd 就绪 → 连接管理路径 → accept 新连接并将 connfd 注册进 epoll connfd 就绪 → 数据路径或关闭路径 → recv 数据，或者识别 EOF 后移除连接 多个客户端的并发体现在：所有 connfd 都在同一棵红黑树中，但某一时刻只有真正就绪的连接进入 rdlist。\n十二、薄弱但非常重要：LT、ET 与“消费事件” 理解整集和就绪集后，还必须区分两种事件语义。\n1. LT：Level Triggered，水平触发 只要可读条件仍然成立，就应该继续报告：\nrcvbuf 里还有数据 → fd 仍然可读 → 下一次 epoll_wait 仍应返回它 应用可以一次只读一部分，剩余数据会让 fd 继续保持就绪。\n2. ET：Edge Triggered，边沿触发 只在状态从“不可读”变为“可读”的边沿通知一次：\nrcvbuf：空 → 非空 → 触发一次 EPOLLIN 如果应用没有把数据一直读到 EAGAIN，而 rcvbuf 始终保持非空，就可能没有新的空→非空边沿，因此也不会再次收到通知。\n3. 当前学习代码的核心行为 epoll_event_callback() 把节点加入 rdlist；nepoll_wait() 返回后将其移出并清除 rdy。以后再次发生协议栈回调时，节点才能重新加入。\n这更接近“协议栈事件通知队列”的实现骨架。要形成严格的 LT 或 ET，还需要把就绪谓词定义清楚：\n监听 fd 可读：accept 队列非空 连接 fd 可读：rcvbuf 非空，或存在 EOF/错误 连接 fd 可写：sndbuf 有可用空间 然后选择策略：\nLT：epoll_wait 返回事件后，如果谓词仍为真，应保留或重新加入就绪集。 ET：只有谓词从假变真时才入就绪集，应用必须把资源处理到再次变为假。 这部分是自实现 epoll 最容易遗漏的核心：就绪链表只是结果，真正决定事件语义的是状态谓词和重新入队规则。\n十三、线程同步：为什么需要三种同步手段 代码中不同锁保护不同对象：\n同步对象 主要保护内容 ep-\u0026gt;mtx 红黑树的 ADD/DEL/MOD ep-\u0026gt;lock 自旋锁 短时间修改 rdlist、rdnum、rdy ep-\u0026gt;cdmtx + cond nepoll_wait 睡眠与协议栈唤醒 1. 为什么等待必须检查条件 正确思路不是“收到 signal 就相信一定有事件”，而是：\nwhile (ep-\u0026gt;rdnum == 0) pthread_cond_wait(...); 条件变量可能虚假唤醒，也可能多个等待者竞争同一批事件。醒来后必须重新检查 rdnum。\n同理，nrecv() 等待数据时也使用循环检查 rcvbuf，而不是单次 if。\n2. 为什么先入就绪集，再 signal 发布顺序是：\n修改受保护状态：epitem 进入 rdlist，rdnum++ → 再 signal signal 的作用只是提醒等待线程重新检查条件；真正可信的是受锁保护的 rdlist/rdnum。\n3. DEL 与回调之间的关系 应用关闭连接时会删除 epitem，协议栈线程则可能正在为同一 fd 触发回调。完整实现必须让红黑树查找、节点删除和就绪链表访问遵守一致的生命周期规则，避免回调继续访问已经释放的节点。\n这里不展开代码审查，只需记住：并发数据结构除了“查得快”，还要保证节点从注册、就绪到删除期间始终有效。\n十四、单 epoll 与多个 epoll 当前代码在创建时执行：\nstruct ng_tcp_table *table = tcpInstance(); table-\u0026gt;ep = ep; 协议栈触发事件时也直接使用：\nepoll_event_callback(table-\u0026gt;ep, stream-\u0026gt;fd, EPOLLIN); 这意味着当前教学实现默认整个 TCP table 只有一个 epoll。它足以展示完整通知链路，但还不能表达：\n同一进程创建多个 epoll 实例； 不同 epoll 注册不同连接； 同一个 fd 同时被多个 epoll 关注。 扩展为多个 epoll 时，不能只保留一个 table-\u0026gt;ep。需要建立：\nepfd → eventpoll 以及 sockfd → 所有注册了该 sockfd 的 epitem/eventpoll 协议栈产生事件后，要找到所有相关订阅者并分别更新它们的就绪集。这实际上把单指针通知扩展成一对多的观察者关系。\n十五、多连接 core dump 在本篇中的位置 多连接时暴露 core dump，说明“单连接路径成立”并不等于“所有连接级资源都已正确隔离”。本节笔记直接涉及的一点是：每条 stream 都会创建自己的 DPDK ring，而 DPDK 命名对象需要可区分的名称。\nsprintf(sbufname, \u0026#34;sndbuf%x%d\u0026#34;, sip, sport); stream-\u0026gt;sndbuf = rte_ring_create(sbufname, ...); sprintf(rbufname, \u0026#34;bufname%x%d\u0026#34;, sip, sport); stream-\u0026gt;rcvbuf = rte_ring_create(rbufname, ...); 这里用客户端 IP 和源端口区分连接 ring。要记住三层不同的身份：\n四/五元组：让协议栈把报文分给正确 stream DPDK ring 名称：让每条 stream 获得独立缓冲对象 用户态 fd：让应用和 epoll 找到正确 stream 本篇不继续展开具体崩溃排查，因为重点是 epoll 的事件闭环。\n十六、把全部实现压缩成一张图 应用线程 │ nepoll_ctl ADD │ 注册兴趣 ▼ ┌───────────────┐ │ eventpoll │ │ │ │ 红黑树 rbr │ ← 全部注册 fd │ 就绪表 rdlist │ ← 当前就绪 fd │ cond │ ← 没事件时睡眠 └───────┬───────┘ │ nepoll_wait 返回 ▼ accept / recv / send / close ▲ │ 用户态 fd → stream ┌───────┴────────┐ │ ng_tcp_stream │ │ 状态/SEQ/ACK │ │ rcvbuf/sndbuf │ └───────▲────────┘ │ ng_tcp_process 按四元组定位 │ 握手完成 / payload 到达 / FIN 到达 │ epoll_event_callback │ 加入 rdlist + signal 从这张图可以看到，TCP 并发 epoll 不是单独一个数据结构，而是四套映射协作：\n报文四元组 → stream 用户态 fd → stream epfd → eventpoll eventpoll 中 sockfd → epitem 缺少任何一层，事件都无法从报文准确传递到应用。\n十七、最终应掌握的实现逻辑 多连接首先要求每条连接拥有独立 TCB、状态、序列号和收发缓冲区。 报文通过四元组找到 stream，应用通过用户态 fd 找到同一个 stream。 一连接一线程让每个线程阻塞等一个 fd；epoll 让一个线程等待一组 fd。 用户态 fd 不属于内核，因此必须自实现 epoll，不能直接交给 Linux epoll。 红黑树保存“关注谁”，就绪链表保存“现在谁能处理”。 nepoll_ctl() 建立兴趣关系，epoll_event_callback() 发布事件，nepoll_wait() 消费事件。 握手完成触发监听 fd 的 EPOLLIN，因为此时 accept 不再阻塞。 payload 或 FIN 到达触发连接 fd 的 EPOLLIN，因为此时 recv 能返回数据或 EOF。 必须先改变真实就绪状态，再把事件加入就绪集并唤醒等待线程。 LT/ET 的本质不在链表名字，而在就绪谓词以及事件消费后的重新入队规则。 单 epoll 可以用 tcp_table-\u0026gt;ep；多个 epoll 需要 fd 到多个订阅者的映射。 epoll 解决应用调度问题，不替代 TCP 状态机，也不能弥补连接状态未隔离的问题。 十八、反刍题 为什么三次握手完成时触发 listenfd，而不是新连接 fd？ 为什么 FIN 可以通过 EPOLLIN 通知？ 红黑树和就绪链表各自回答什么问题？ 为什么一个 epitem 要同时包含 RB_ENTRY 和 LIST_ENTRY？ nepoll_ctl() 注册了一个 fd 后，为什么它不一定立刻出现在就绪链表？ 从 payload 到达到 nepoll_wait() 返回，中间依次发生了什么？ 如果 rcvbuf 仍有未读数据，LT 和 ET 下一步分别应该怎样表现？ 为什么条件变量的等待必须放在 while 中？ 为什么发布事件时要先更新 rdlist/rdnum，再调用 signal？ 当前的 tcp_table-\u0026gt;ep 为什么只能自然表达单 epoll？ 如果一个 connfd 被两个 epoll 实例注册，协议栈该怎样分发一次 EPOLLIN？ epoll 已经实现正确，但不同连接仍共用 rcvbuf，并发为什么仍然会失败？ ","date":"2026-08-18T00:00:00+08:00","image":"/MyBlog/p/dpdk-userspace-tcp-concurrency-epoll/cover.svg","permalink":"/MyBlog/p/dpdk-userspace-tcp-concurrency-epoll/","title":"DPDK 用户态 TCP 协议栈（四）：并发与自实现 epoll"},{"content":" 系列前文：\nDPDK 用户态协议栈设计与实现 DPDK 用户态协议栈（二）：从收包到 UDP Echo 与 TCP 握手 前两篇已经讲过 DPDK 的收发路径、mbuf、Ethernet/IPv4/UDP/TCP Header、UDP Echo 和基础 TCP 握手。本篇直接沿着当前代码的执行顺序复习 TCP，并从代码中的 flags、seq、ack、rx_win、tcp_status 延伸到滑动窗口、延迟确认、重传、慢启动和拥塞控制。\n一、先看当前代码的 TCP 主线 当前程序的 TCP 数据路径可以概括为：\nrte_eth_rx_burst() 从 RX Queue 取包 ↓ 解析 Ethernet Header ↓ 解析 IPv4 Header ↓ next_proto_id == IPPROTO_TCP ↓ 解析 TCP Header ↓ 保存源/目的 MAC、IP、端口 ↓ 读取 flags、seq、ack ↓ 根据 tcp_status 处理 SYN、ACK、PSH ↓ 必要时构造 TCP 响应并通过 TX Queue 发出 代码中与 TCP 直接相关的全局信息是：\nuint8_t global_flags; uint32_t global_seqnum; uint32_t global_acknum; 它们分别保存当前收到的 TCP 报文中的：\nglobal_flags ← TCP 控制位 global_seqnum ← 对方本次发送的 SEQ global_acknum ← 对方本次携带的 ACK 状态机由下面的枚举描述：\ntypedef enum __USTACK_TCP_STATUS { USTACK_TCP_STATUS_CLOSED = 0, USTACK_TCP_STATUS_LISTEN, USTACK_TCP_STATUS_SYN_RCVD, USTACK_TCP_STATUS_SYN_SENT, USTACK_TCP_STATUS_ESTABLISHED, USTACK_TCP_STATUS_FIN_WAIT_1, USTACK_TCP_STATUS_FIN_WAIT_2, USTACK_TCP_STATUS_CLOSING, USTACK_TCP_STATUS_TIMEWAIT, USTACK_TCP_STATUS_CLOSE_WAIT, USTACK_TCP_STATUS_LAST_ACK } USTACK_TCP_STATUS; uint8_t tcp_status = USTACK_TCP_STATUS_LISTEN; 当前代码主要走的是服务端路径：\nLISTEN ↓ 收到 SYN SYN_RCVD ↓ 收到第三次握手 ACK ESTABLISHED ↓ 接收 TCP 数据 二、代码怎样识别并取得 TCP 报文 主循环先通过 DPDK 批量收包：\nstruct rte_mbuf *mbufs[BURST_SIZE] = {0}; uint16_t num_recvd = rte_eth_rx_burst( global_portid, 0, mbufs, BURST_SIZE ); num_recvd 表示本次从 RX Queue 中取出的报文数量。程序随后遍历每一个 mbuf：\nfor (i = 0; i \u0026lt; num_recvd; i++) { struct rte_ether_hdr *ethhdr = rte_pktmbuf_mtod( mbufs[i], struct rte_ether_hdr * ); 先判断它是不是 IPv4：\nif (ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { continue; } 再取得 IPv4 Header：\nstruct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset( mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr) ); 最后根据 IPv4 Header 中的协议号进入 TCP 分支：\nif (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { struct rte_tcp_hdr *tcphdr = (struct rte_tcp_hdr *)(iphdr + 1); } 在当前实验中，IPv4 Header 按基础 20 字节处理，所以使用 (iphdr + 1) 到达 TCP Header。进一步扩展时，可以根据 IHL 取得真实的 IPv4 Header 长度：\nuint16_t ip_hdr_len = (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; struct rte_tcp_hdr *tcphdr = (struct rte_tcp_hdr *) ((uint8_t *)iphdr + ip_hdr_len); 这里的层次关系是：\nEthernet Header IPv4 Header TCP Header TCP Payload 三、为什么代码要把地址和端口反过来保存 收到客户端发来的 TCP 包后，服务端回包的方向与收包方向正好相反。\n1. MAC 地址交换 rte_memcpy( global_smac, ethhdr-\u0026gt;d_addr.addr_bytes, RTE_ETHER_ADDR_LEN ); rte_memcpy( global_dmac, ethhdr-\u0026gt;s_addr.addr_bytes, RTE_ETHER_ADDR_LEN ); 收到包时：\n源 MAC = 客户端 MAC 目的 MAC = 服务端 MAC 回包时：\n源 MAC = 服务端 MAC 目的 MAC = 客户端 MAC 所以把收到包的目的 MAC 保存为回包源 MAC，把收到包的源 MAC 保存为回包目的 MAC。\n2. IP 地址交换 rte_memcpy(\u0026amp;global_sip, \u0026amp;iphdr-\u0026gt;dst_addr, sizeof(uint32_t)); rte_memcpy(\u0026amp;global_dip, \u0026amp;iphdr-\u0026gt;src_addr, sizeof(uint32_t)); 3. TCP 端口交换 rte_memcpy(\u0026amp;global_sport, \u0026amp;tcphdr-\u0026gt;dst_port, sizeof(uint16_t)); rte_memcpy(\u0026amp;global_dport, \u0026amp;tcphdr-\u0026gt;src_port, sizeof(uint16_t)); 最终得到一组回包方向的数据：\nglobal_smac 服务端 MAC global_dmac 客户端 MAC global_sip 服务端 IP global_dip 客户端 IP global_sport 服务端端口 global_dport 客户端端口 TCP 用下面的四元组区分连接：\n源 IP、源端口、目的 IP、目的端口 对于服务端而言，一般写成：\nlocal_ip、local_port、remote_ip、remote_port 四、代码怎样读取 Flags、SEQ 和 ACK global_flags = tcphdr-\u0026gt;tcp_flags; global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 1. Flags tcp_flags 是一个字节，其中每一位代表一个控制标志：\nSYN 建立连接并同步初始序列号 ACK ACK 字段有效 PSH 希望接收端尽快把数据交给应用 FIN 本方向没有更多数据需要发送 RST 复位连接 URG Urgent Pointer 有效 一个报文可以同时带多个标志，例如：\nSYN | ACK PSH | ACK FIN | ACK 所以代码使用按位与判断某一位是否存在：\nif (global_flags \u0026amp; RTE_TCP_SYN_FLAG) { /* 包含 SYN */ } 2. SEQ 与 ACK 是 32 位字段 global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 报文使用网络字节序，程序进行加减和比较时使用主机字节序，因此读取时调用 ntohl()。\n回包时方向相反：\ntcp-\u0026gt;sent_seq = htonl(seq); tcp-\u0026gt;recv_ack = htonl(ack); 可以记成：\n网络报文 → 主机整数：ntohs / ntohl 主机整数 → 网络报文：htons / htonl 其中：\n16 位：端口、Window、部分长度字段 32 位：IPv4 地址、SEQ、ACK 五、结合 ustack_encode_tcp_pkt() 看 SYN+ACK 的构造 当前编码函数依次组织 Ethernet、IPv4 和 TCP Header。\n1. Ethernet Header struct rte_ether_hdr *eth = (struct rte_ether_hdr *)msg; rte_memcpy( eth-\u0026gt;d_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN ); rte_memcpy( eth-\u0026gt;s_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN ); eth-\u0026gt;ether_type = htons(RTE_ETHER_TYPE_IPV4); 这一层说明后面的载荷是 IPv4。\n2. IPv4 Header struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); ip-\u0026gt;version_ihl = 0x45; ip-\u0026gt;type_of_service = 0; ip-\u0026gt;total_length = htons( total_len - sizeof(struct rte_ether_hdr) ); ip-\u0026gt;packet_id = 0; ip-\u0026gt;fragment_offset = 0; ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_TCP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = 0; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); total_len 是整个 Ethernet Frame 中由程序填写的长度。IPv4 的 total_length 不包括 Ethernet Header，所以要减去：\nsizeof(struct rte_ether_hdr) 3. TCP Header struct rte_tcp_hdr *tcp = (struct rte_tcp_hdr *)(ip + 1); tcp-\u0026gt;src_port = global_sport; tcp-\u0026gt;dst_port = global_dport; tcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); tcp-\u0026gt;data_off = 0x50; tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; tcp-\u0026gt;rx_win = htons(TCP_INIT_WINDOWS); tcp-\u0026gt;cksum = 0; tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 这几行正好对应三次握手第二步：\n服务端自己的 SEQ = 12345 确认客户端 SYN 的 ACK = 客户端 SEQ + 1 Flags = SYN | ACK data_off = 0x50 的高 4 位是 5，表示 TCP Header 长度为：\n5 × 4 字节 = 20 字节 本例没有 TCP Options，所以是最基础的 20 字节 TCP Header。\nrx_win 是服务端通告给客户端的接收窗口。它是 16 位字段，写入报文时使用 htons()。\n六、结合状态机完整理解三次握手 假设：\n客户端初始序列号 client_isn = 1234 服务端初始序列号 server_isn = 12345 第一次握手：客户端发送 SYN 客户端 → 服务端 SYN=1 SEQ=1234 代码在 TCP 分支中读取到：\nglobal_flags = RTE_TCP_SYN_FLAG; global_seqnum = 1234; 随后进入：\nif (global_flags \u0026amp; RTE_TCP_SYN_FLAG) { if (tcp_status == USTACK_TCP_STATUS_LISTEN) { /* 构造 SYN+ACK */ } } 第二次握手：服务端回复 SYN+ACK 服务端 → 客户端 SYN=1 ACK=1 SEQ=12345 ACK number=1235 代码中的对应关系：\ntcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; 发送后进入：\ntcp_status = USTACK_TCP_STATUS_SYN_RCVD; 表示服务端已经发送 SYN+ACK，正在等待客户端确认自己的 SYN。\n第三次握手：客户端回复 ACK 客户端 → 服务端 ACK=1 SEQ=1235 ACK number=12346 客户端的 SEQ 变成 1235，是因为它的 SYN 占用了一个序列号。\n客户端的 ACK 是 12346，是因为服务端的 SYN 也占用了一个序列号。\n代码收到 ACK 后：\nif (global_flags \u0026amp; RTE_TCP_ACK_FLAG) { if (tcp_status == USTACK_TCP_STATUS_SYN_RCVD) { printf(\u0026#34;enter established\\n\u0026#34;); tcp_status = USTACK_TCP_STATUS_ESTABLISHED; } } 于是状态变化为：\nLISTEN ↓ 收到 SYN SYN_RCVD ↓ 收到确认服务端 SYN 的 ACK ESTABLISHED 在连接数据结构中，可以保存：\niss = 12345 服务端初始发送序列号 irs = 1234 客户端初始发送序列号 snd_una = 12346 服务端最早未确认序号 snd_nxt = 12346 服务端下一个发送序号 rcv_nxt = 1235 服务端下一步期望收到的客户端序号 七、SEQ 的精髓：它是字节序号 TCP 提供的是字节流。SEQ 不是“第几个包”，而是当前 TCP 段中第一个数据字节的编号。\n假设服务端当前发送：\nSEQ=12345 payload_len=100 那么该段中的 100 个数据字节对应：\n12345～12444 下一段新数据从：\nSEQ=12445 开始。\n如果第二段有 500 字节：\n第二段：SEQ=12445，覆盖 12445～12944 第三段：SEQ=12945 所以发送新数据后：\nSND.NXT = SND.NXT + payload_len 如果报文还包含 SYN 或 FIN，则它们各自还要消耗一个序列号：\nsequence_space_len = payload_len + (SYN ? 1 : 0) + (FIN ? 1 : 0) ACK 和 PSH 本身不占序列空间。\n八、ACK 的精髓：确认连续收到的字节 假设服务端收到客户端的数据：\nSEQ=5000 payload_len=1000 数据覆盖：\n5000～5999 如果这些字节都按序收到，服务端返回：\nACK=6000 ACK=6000 表示：\n6000 以前的连续序列字节已经收到，下一步期望 6000。 因此 ACK 是累计确认。假设发送方连续发送：\n[5000, 6000) [6000, 7000) [7000, 8000) 接收方可以直接回复：\nACK=8000 一次确认前面三个连续区间。\n在代码中，接收端用于生成 ACK 的核心状态可以写成：\nconn-\u0026gt;rcv_nxt += payload_len; tcp-\u0026gt;recv_ack = htonl(conn-\u0026gt;rcv_nxt); 九、一条 TCP 连接为什么有两套 SEQ/ACK TCP 是全双工协议，两个方向可以同时传输数据：\n客户端 ─────客户端数据────→ 服务端 客户端 ←────服务端数据───── 服务端 两个方向分别有自己的序列空间：\n客户端发送方向：从 client_isn 开始编号 服务端发送方向：从 server_isn 开始编号 一个 TCP 报文中的两个字段分别表达：\nSEQ：我这次发送的数据从哪里开始 ACK：你发送的数据，我连续收到了哪里 使用“北京与广州互相运货”的类比：\n北京 → 广州：烤鸭编号是一套序列空间 广州 → 北京：白切鸡编号是另一套序列空间 每辆车都可以同时携带：\nSEQ：本车货物的起始编号 ACK：反方向货物已经连续收到的编号 Window：本地仓库还允许对方运来多少货物 所以双方都同时具有发送状态和接收状态。\n十、代码怎样定位 TCP Payload 原代码在收到 PSH 后，通过 TCP Header 长度定位数据：\nuint8_t hdrlen = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * sizeof(uint32_t); uint8_t *data = (uint8_t *)tcphdr + hdrlen; data_off 的高 4 位以 4 字节为单位表示 TCP Header 长度：\ntcp_hdr_len = (data_off \u0026gt;\u0026gt; 4) × 4 若没有 TCP Options：\ndata_off 高四位 = 5 TCP Header 长度 = 5 × 4 = 20 字节 TCP payload 长度可以通过 IPv4 总长度计算：\nuint16_t ip_total_len = ntohs(iphdr-\u0026gt;total_length); uint16_t ip_hdr_len = (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; uint16_t tcp_hdr_len = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; uint16_t payload_len = ip_total_len - ip_hdr_len - tcp_hdr_len; uint8_t *payload = (uint8_t *)tcphdr + tcp_hdr_len; 这也解释了 TCP Header 为什么不需要像 UDP Header 那样再放一个长度字段：IPv4 总长度、IPv4 Header 长度和 TCP Header 长度已经能够计算出当前 TCP payload 长度。\nPSH 可以理解为“希望接收端尽快把这部分数据交给应用”。处理代码时，是否存在 payload 仍以 payload_len \u0026gt; 0 为准。\n十一、从 rx_win 理解 TCP 接收窗口 当前构造 TCP Header 时写入：\n#define TCP_INIT_WINDOWS 14600 tcp-\u0026gt;rx_win = htons(TCP_INIT_WINDOWS); 这个字段是本端向对端通告：\n从 ACK 所表示的下一个期望字节开始， 我目前还愿意接收多少字节。 例如服务端回复：\nACK=6000 Window=14600 可以理解为：\n6000 以前已经连续收到； 从 6000 开始，当前还能接收 14600 字节。 接收窗口通常与接收缓存关联：\nRCV.WND = 接收缓存总容量 - 已收到但应用还没有读取的数据量 如果应用读取很慢，缓存逐渐占满：\nRCV.WND 逐渐减小 如果缓存完全占满：\nRCV.WND = 0 发送方看到零窗口后，暂停发送新的数据。接收端应用继续读取数据、释放缓存后，再通过 ACK 中的 Window 字段通告新的可用空间。\n十二、发送窗口：SND.UNA 与 SND.NXT 发送端可以把序列空间分成四部分：\n已发送已确认 已发送未确认 窗口内尚未发送 当前不能发送 ────────────┬────────────────┬──────────────────┬──────────→ SND.UNA SND.NXT SND.UNA+SND.WND 1. SND.UNA Send Unacknowledged，最早一个尚未被确认的序号。\n2. SND.NXT Send Next，下一个新数据应该使用的序号。\n3. 在途数据量 flight_size = SND.NXT - SND.UNA 表示已经发送、还没有累计确认的数据量。\n4. 收到新 ACK 时窗口滑动 发送端原来是：\nSND.UNA=1000 SND.NXT=4000 说明 [1000, 4000) 已发送未确认。\n收到：\nACK=3000 之后：\nSND.UNA=3000 SND.NXT=4000 [1000, 3000) 已被确认，可以从重传队列和发送缓存中释放；窗口左边界向右移动，这就是“滑动窗口”。\n十三、接收窗口与有序交付 接收端维护：\nRCV.NXT：下一步期望收到的序号 RCV.WND：从 RCV.NXT 开始还允许接收多少字节 序列空间可以表示为：\n已经连续收到 当前接收窗口 窗口之外 ───────────────┬─────────────────────────┬────────────→ RCV.NXT RCV.NXT+RCV.WND 假设依次发送四段：\nA：[0, 400) B：[400, 900) C：[900, 1600) D：[1600, 2000) 按序到达 收到 A：\nRCV.NXT 从 0 变成 400 收到 B：\nRCV.NXT 从 400 变成 900 C 暂时没有到达，D 先到 接收端当前仍缺少从 900 开始的数据，因此：\nRCV.NXT 仍为 900 ACK 仍回复 900 D 可以暂存在乱序队列中 当 C 到达后：\nC 与已经缓存的 D 连续 RCV.NXT 可以从 900 一次推进到 2000 ACK 回复 2000 对应用程序而言，TCP 仍然按顺序交付：\nA → B → C → D 这就是 TCP 利用字节序号、累计 ACK 和乱序缓存实现有序字节流的过程。\n十四、延迟确认怎样接入接收逻辑 收到数据后，接收端需要用 ACK 告诉发送端 RCV.NXT。ACK 可以有三种发送方式：\n立即发送纯 ACK 延迟一小段时间后发送 ACK 把 ACK 捎带在反方向数据中 延迟确认的主要目标是减少纯 ACK 数量，并等待是否有反方向数据可以一起发送。\n可以先用下面的学习模型理解：\n收到第一个按序数据段 → 更新 RCV.NXT → 设置 pending_ack → 启动 delayed ACK 定时器 定时器到期前又收到一个按序数据段 → 再次更新 RCV.NXT → 立即发送累计 ACK 定时器到期 → 发送 ACK=RCV.NXT 期间本端正好有数据要发 → 把 ACK=RCV.NXT 填入数据段 → 不再额外发送纯 ACK 遇到乱序段时，可以及时回复当前 RCV.NXT，让发送方看到重复 ACK 并发现连续数据中的缺口。\n延迟确认属于接收端的 ACK 生成策略；超时重传和快速重传则是发送端恢复丢失数据的策略。\n十五、为什么发送过的数据要暂时保存 调用：\nrte_eth_tx_burst( global_portid, 0, \u0026amp;mbuf, 1 ); 表示把报文提交给 DPDK TX Queue。对于 TCP 来说，提交发送以后还要等待对方 ACK，才能确认该序列范围已经到达。\n因此 TCP 需要发送缓存和重传队列：\n应用写入数据 ↓ 数据进入发送缓存 ↓ 按 MSS 和窗口切成 TCP 段 ↓ 记录每段的 seq_start、seq_end、发送时间 ↓ 构造 mbuf 并提交 TX ↓ 等待 ACK ↓ 收到累计 ACK 后释放已确认的数据 学习版重传项可以记录：\nstruct tcp_tx_segment { uint32_t seq_start; uint32_t seq_end; uint16_t payload_len; uint8_t flags; uint64_t first_tx_us; uint64_t last_tx_us; uint32_t retransmit_count; }; 例如：\n重传队列： [1000, 2000) [2000, 3000) [3000, 4000) 收到 ACK=3000 后：\n[1000, 2000) 已确认，移除 [2000, 3000) 已确认，移除 [3000, 4000) 继续等待 十六、超时重传：没有收到 ACK 时怎么办 发送一个 TCP 段时，发送端记录发送时间并启动重传定时器。如果在 RTO 到期前没有收到能够确认它的 ACK，就重新发送最早未确认的数据。\n发送数据段 → 保存到重传队列 → 启动 RTO 定时器 → 收到新 ACK：推进 SND.UNA，重启或停止定时器 → RTO 到期：重传最早未确认段 1. RTT RTT 是数据发出到相应 ACK 返回所经历的往返时间：\nsend_time ───────────────────── ack_time RTT 网络时延会变化，因此不能只用某一次 RTT 作为固定重传时间。\n2. SRTT、RTTVAR 与 RTO TCP 对 RTT 做平滑，并记录波动程度：\nSRTT 平滑 RTT RTTVAR RTT 波动估计 RTO 重传超时时间 核心关系是：\nRTO = SRTT + max(G, 4 × RTTVAR) 其中 G 是定时器时钟粒度。\n第一次取得 RTT 样本 R 时：\nSRTT = R RTTVAR = R / 2 后续样本继续对两者进行平滑更新，使 RTO 能随网络情况变化。\n3. RTO 指数退避 如果重传后仍然没有 ACK：\nRTO = RTO × 2 继续超时则继续退避：\n1RTO → 2RTO → 4RTO → 8RTO 这样可以避免网络已经拥塞时还以很高频率不断重传。\n4. DPDK 主循环中的定时器 定时器处理不能阻塞轮询收包。主循环可以组织为：\nwhile (1) { 处理 RX burst 处理到期的 TCP timers 发送 TX 待发送队列 } 需要处理的 TCP 定时器可以逐步包括：\nRTO timer delayed ACK timer zero-window persist timer TIME_WAIT timer 十七、快速重传：通过重复 ACK 更早发现缺口 继续使用下面的序列：\nA：[0, 400) B：[400, 900) C：[900, 1600) ← 丢失 D：[1600, 2000) E：[2000, 2400) F：[2400, 2800) 接收端收到 D、E、F 时，由于 C 仍未到达，连续前缀一直停在 900，于是多次回复：\nACK=900 ACK=900 ACK=900 发送端看到多个重复 ACK，可以在 RTO 之前推断从 900 开始的数据可能丢失。\n经典快速重传通常使用：\n3 个重复 ACK → 重传从 SND.UNA 开始的缺失段 如果握手时协商了 SACK，接收端还能在 TCP Options 中告诉发送端：\n[1600, 2800) 已经收到 发送端便能更准确地只补发缺失区间 [900, 1600)。\n十八、接收窗口 rwnd 与拥塞窗口 cwnd 发送方决定本轮还能发送多少数据时，要同时考虑两种窗口。\n1. rwnd：接收端通告窗口 来源：\ntcphdr-\u0026gt;rx_win 含义：\n对方接收缓存还能容纳多少数据 它解决的是流量控制问题，避免发送速度超过对方应用和接收缓存的处理能力。\n2. cwnd：拥塞窗口 cwnd 由发送端本地维护，不直接写在 TCP Header 中。\n含义：\n根据当前网络状态，允许多少数据处于在途未确认状态 它解决的是拥塞控制问题，避免一次向网络注入过多数据。\n3. 两个窗口共同限制发送 send_limit = min(rwnd, cwnd) flight_size = SND.NXT - SND.UNA available = send_limit - flight_size 例一：\nrwnd = 64 KB cwnd = 12 KB flight_size = 8 KB available = min(64, 12) - 8 = 4 KB 例二：\nrwnd = 4 KB cwnd = 32 KB flight_size = 3 KB available = min(4, 32) - 3 = 1 KB 第一个例子受网络拥塞窗口限制，第二个例子受对端接收窗口限制。\n十九、慢启动：逐步探测网络容量 连接刚建立时，发送端并不知道路径能容纳多少在途数据，所以用 cwnd 逐步试探。\n为了理解增长过程，先假设：\nSMSS = 1000 字节 初始 cwnd = 1 × SMSS 第一个 RTT cwnd=1 MSS 发送 1 个 MSS 收到确认新数据的 ACK cwnd 增长到约 2 MSS 第二个 RTT cwnd=2 MSS 发送 2 个 MSS 收到相应 ACK cwnd 增长到约 4 MSS 第三个 RTT cwnd=4 MSS 发送 4 个 MSS cwnd 增长到约 8 MSS 所以慢启动的教学曲线经常写作：\n1 → 2 → 4 → 8 → 16 它表示每经过一个 RTT，窗口大致成倍增长。具体实现中，初始窗口可以大于 1 MSS；理解重点是：\n慢启动阶段每收到确认新数据的 ACK，cwnd 增加； 一轮 RTT 中 ACK 数量随已发送数据增加，因此整体近似指数增长。 二十、拥塞避免：超过阈值后降低增长速度 TCP 连接还维护：\nssthresh：慢启动阈值 两种阶段的关系是：\ncwnd \u0026lt; ssthresh → 慢启动 cwnd \u0026gt; ssthresh → 拥塞避免 慢启动阶段增长较快；进入拥塞避免后，经典算法让 cwnd 每个 RTT 大约增加一个 MSS。\n慢启动：近似指数增长 拥塞避免：近似线性增长 用窗口大小表示：\n慢启动：1、2、4、8、16 拥塞避免：16、17、18、19、20 实际数值由 ACK、MSS 和算法计算，不一定正好是整数段；这组数字主要用于观察增长形态。\n发生 RTO 时 经典学习模型中：\nssthresh = 当前在途数据量的一半，且至少为 2 MSS cwnd = 1 MSS 重新进入慢启动 发生 3 个重复 ACK 时 说明后续数据仍在到达，发送端执行：\n快速重传 调整 ssthresh 进入快速恢复 这几个机制组合起来就是经典 TCP Reno 的核心：\n慢启动 拥塞避免 快速重传 快速恢复 二十一、把当前全局状态整理成一条连接的数据结构 目前的全局变量适合单连接实验。为了把已学习的概念放进代码，可以先整理成学习版连接控制块：\nstruct tcp_connection { /* 四元组 */ uint32_t local_ip; uint32_t remote_ip; uint16_t local_port; uint16_t remote_port; USTACK_TCP_STATUS state; /* 初始序列号 */ uint32_t iss; uint32_t irs; /* 发送方向 */ uint32_t snd_una; uint32_t snd_nxt; uint32_t snd_wnd; /* 接收方向 */ uint32_t rcv_nxt; uint32_t rcv_wnd; /* 重传定时器 */ uint64_t srtt_us; uint64_t rttvar_us; uint64_t rto_us; uint64_t rto_deadline_us; /* 拥塞控制 */ uint32_t smss; uint32_t cwnd; uint32_t ssthresh; /* 发送缓存、重传队列、接收缓存、乱序队列 */ }; 字段与知识点的对应关系：\n代码字段 TCP 含义 state 当前连接状态 iss 本端初始发送序列号 irs 对端初始发送序列号 snd_una 最早未确认的发送序号 snd_nxt 下一个新数据发送序号 snd_wnd 对端通告给本端的接收窗口 rcv_nxt 本端下一步期望收到的序号 rcv_wnd 本端当前可用接收窗口 srtt_us 平滑 RTT rttvar_us RTT 波动估计 rto_us 当前重传超时时间 cwnd 拥塞窗口 ssthresh 慢启动阈值 收到 TCP 报文后，通过四元组找到这条连接：\n(local_ip, local_port, remote_ip, remote_port) ↓ tcp_connection 每个客户端都有自己的状态、SEQ/ACK、窗口、缓存和定时器。\n二十二、ESTABLISHED 状态收到数据时的完整思路 当前代码在看见 PSH 后取得并打印数据：\nif (global_flags \u0026amp; RTE_TCP_PSH_FLAG) { if (tcp_status == USTACK_TCP_STATUS_ESTABLISHED) { uint8_t hdrlen = (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * sizeof(uint32_t); uint8_t *data = (uint8_t *)tcphdr + hdrlen; printf(\u0026#34;tcp data: %s\\n\u0026#34;, data); } } 把可靠传输补充进来以后，可以按下面的顺序理解：\n收到 ESTABLISHED 状态报文 ↓ 计算 ip_hdr_len、tcp_hdr_len、payload_len ↓ 读取 SEG.SEQ、SEG.ACK、SEG.WND ↓ 处理 ACK：推进 snd_una ↓ 判断 SEG.SEQ 与 rcv_nxt 的关系 ↓ 按序数据写入接收缓存；乱序数据进入乱序队列 ↓ 推进 rcv_nxt ↓ 根据缓存剩余量更新 rcv_wnd ↓ 立即 ACK、延迟 ACK，或在反向数据中携带 ACK ↓ 根据 min(snd_wnd, cwnd) 尝试继续发送 学习版伪代码：\nif (conn-\u0026gt;state == USTACK_TCP_STATUS_ESTABLISHED) { tcp_process_ack(conn, seg_ack, seg_window); if (payload_len \u0026gt; 0) { if (seg_seq == conn-\u0026gt;rcv_nxt) { recv_buffer_write(conn, payload, payload_len); conn-\u0026gt;rcv_nxt += payload_len; consume_contiguous_out_of_order_data(conn); conn-\u0026gt;rcv_wnd = recv_buffer_free_space(conn); schedule_or_send_ack(conn); } else if (seq_after(seg_seq, conn-\u0026gt;rcv_nxt)) { queue_out_of_order_data( conn, seg_seq, payload, payload_len ); send_ack(conn, conn-\u0026gt;rcv_nxt); } else { send_ack(conn, conn-\u0026gt;rcv_nxt); } } } 这段伪代码把当前“读取 payload”的路径继续延伸为：\n有序接收 + 累计确认 + 乱序缓存 + 接收窗口 二十三、发送数据时的完整思路 当服务端应用有数据需要发送时：\n应用数据进入发送缓存 ↓ 读取 snd_wnd 和 cwnd ↓ 计算当前还能发送多少 ↓ 按 SMSS 切分 ↓ 每段 SEQ = snd_nxt ACK = rcv_nxt Window = rcv_wnd ↓ 保存到重传队列 ↓ 构造 Ethernet + IPv4 + TCP + payload ↓ rte_eth_tx_burst() ↓ snd_nxt 增加 payload_len ↓ 启动或维护 RTO 定时器 核心计算：\nuint32_t flight_size = conn-\u0026gt;snd_nxt - conn-\u0026gt;snd_una; uint32_t send_limit = RTE_MIN(conn-\u0026gt;snd_wnd, conn-\u0026gt;cwnd); if (send_limit \u0026gt; flight_size) { uint32_t available = send_limit - flight_size; uint32_t send_len = RTE_MIN(available, conn-\u0026gt;smss); /* 构造一个 send_len 字节的数据段 */ } 构造数据段时：\ntcp-\u0026gt;sent_seq = htonl(conn-\u0026gt;snd_nxt); tcp-\u0026gt;recv_ack = htonl(conn-\u0026gt;rcv_nxt); tcp-\u0026gt;tcp_flags = RTE_TCP_ACK_FLAG | RTE_TCP_PSH_FLAG; tcp-\u0026gt;rx_win = htons(conn-\u0026gt;rcv_wnd); 发送后：\nconn-\u0026gt;snd_nxt += send_len; 收到累计 ACK 后：\nconn-\u0026gt;snd_una = seg_ack; 于是发送窗口不断向右滑动。\n二十四、用一次完整传输串起所有概念 假设握手结束后：\n服务端 snd_una = 12346 服务端 snd_nxt = 12346 服务端 rcv_nxt = 1235 对端通告 snd_wnd = 6000 服务端 cwnd = 3000 SMSS = 1000 1. 计算发送能力 flight_size = 12346 - 12346 = 0 send_limit = min(6000, 3000) = 3000 available = 3000 - 0 = 3000 服务端可以先发送 3 个 1000 字节段：\n段 1：SEQ=12346，范围 [12346,13346) 段 2：SEQ=13346，范围 [13346,14346) 段 3：SEQ=14346，范围 [14346,15346) 发送后：\nsnd_una = 12346 snd_nxt = 15346 flight_size = 3000 2. 对端累计确认 如果三个段都按序收到，对端回复：\nACK=15346 服务端处理后：\nsnd_una = 15346 snd_nxt = 15346 flight_size = 0 三个段都可以从重传队列中移除。\n3. 慢启动增加 cwnd ACK 确认了新数据，慢启动阶段继续增加 cwnd，下一轮允许更多在途数据，但仍受 snd_wnd 限制。\n4. 中间一段丢失 如果第二段丢失，第三段到达，对端连续前缀只能到 13346，因此回复：\nACK=13346 继续收到后续乱序段时仍回复相同 ACK。发送端可以通过重复 ACK 快速重传第二段；如果没有足够的后续 ACK，则由 RTO 定时器最终触发重传。\n这一过程把以下概念连在了一起：\nSEQ ACK 滑动窗口 接收窗口 拥塞窗口 慢启动 乱序队列 快速重传 超时重传 二十五、代码与 TCP 知识点对照表 当前代码 对应 TCP 含义 后续实现中保存的位置 tcphdr-\u0026gt;sent_seq 当前段第一个序列号 SEG.SEQ tcphdr-\u0026gt;recv_ack 对端累计确认号 SEG.ACK tcphdr-\u0026gt;tcp_flags SYN/ACK/PSH/FIN/RST 当前报文状态输入 tcphdr-\u0026gt;rx_win 报文发送方通告的接收窗口 本端 snd_wnd tcp-\u0026gt;rx_win 本端向对方通告的接收窗口 本端 rcv_wnd tcp_status 当前连接状态 conn-\u0026gt;state global_seqnum + 1 确认一个 SYN conn-\u0026gt;rcv_nxt 12345 学习代码中的服务端 ISN conn-\u0026gt;iss data_off \u0026gt;\u0026gt; 4 TCP Header 的 32 位字数量 计算 tcp_hdr_len rte_eth_tx_burst() 把报文交给 TX Queue 发送路径 TCP_INIT_WINDOWS 初始接收窗口示例 conn-\u0026gt;rcv_wnd 尚未加入 最早未确认序号 conn-\u0026gt;snd_una 尚未加入 下一个新发送序号 conn-\u0026gt;snd_nxt 尚未加入 下一个期望接收序号 conn-\u0026gt;rcv_nxt 尚未加入 重传定时器 conn-\u0026gt;rto_us 尚未加入 拥塞窗口 conn-\u0026gt;cwnd 尚未加入 慢启动阈值 conn-\u0026gt;ssthresh 二十六、整条 TCP 处理路径 flowchart TD A[\"rte_eth_rx_burst 收包\"] --\u003e B[\"解析 Ethernet 和 IPv4\"] B --\u003e C[\"定位 TCP Header\"] C --\u003e D[\"读取四元组、Flags、SEQ、ACK、Window\"] D --\u003e E[\"按四元组查找连接\"] E --\u003e F{\"连接状态\"} F --\u003e|\"LISTEN + SYN\"| G[\"记录客户端 ISN，发送 SYN+ACK\"] G --\u003e H[\"进入 SYN_RCVD\"] F --\u003e|\"SYN_RCVD + ACK\"| I[\"确认服务端 SYN\"] I --\u003e J[\"进入 ESTABLISHED\"] F --\u003e|\"ESTABLISHED\"| K[\"处理 ACK，推进 SND.UNA\"] K --\u003e L[\"释放已确认发送缓存，更新 RTT/RTO/cwnd\"] F --\u003e|\"ESTABLISHED + payload\"| M[\"按 SEQ 处理按序或乱序数据\"] M --\u003e N[\"推进 RCV.NXT，更新 RCV.WND\"] N --\u003e O[\"发送或延迟 ACK\"] L --\u003e P[\"计算 min(rwnd,cwnd)-flight_size\"] P --\u003e Q[\"窗口允许时继续发送\"] Q --\u003e R[\"数据进入重传队列并提交 TX\"] S[\"RTO 定时器\"] --\u003e T[\"超时重传并调整拥塞状态\"] T --\u003e R 二十七、复习时抓住的主线 第一条：连接状态 LISTEN → SYN_RCVD → ESTABLISHED 状态决定当前收到的 Flags 应该怎样解释。\n第二条：字节序列 SEQ 标识本方向发送的数据 ACK 确认反方向连续收到的数据 第三条：发送可靠性 数据发出后保留 ACK 到达后释放 重复 ACK 可触发快速重传 RTO 到期触发超时重传 第四条：有序接收 RCV.NXT 之前已经连续收到 等于 RCV.NXT 的数据可以推进确认号 更大的 SEQ 暂存到乱序队列 缺口补齐后连续交付给应用 第五条：发送数量 rwnd 保护接收端 cwnd 保护网络 实际发送上限取二者较小值 第六条：网络探测 慢启动快速扩大 cwnd 到达 ssthresh 后进入拥塞避免 丢包或超时后降低发送强度 二十八、总结 当前代码用下面几行打开了理解 TCP 的入口：\nglobal_flags = tcphdr-\u0026gt;tcp_flags; global_seqnum = ntohl(tcphdr-\u0026gt;sent_seq); global_acknum = ntohl(tcphdr-\u0026gt;recv_ack); 沿着它们继续向下，就是完整的 TCP 传输控制：\nFlags 驱动连接状态机 SEQ 给每个方向的数据字节编号 ACK 累计确认已经连续收到的字节 SND.UNA 与 SND.NXT 形成发送滑动窗口 RCV.NXT 与 RCV.WND 管理有序接收和接收缓存 发送缓存保留尚未确认的数据 重复 ACK 与 RTO 负责发现并恢复丢失 rwnd 控制接收端承受能力 cwnd、ssthresh、慢启动和拥塞避免控制网络中的在途数据 因此，从当前代码继续实现 TCP 的核心方向，就是把现在的全局 flags、seq、ack、tcp_status 扩展为每条连接独立的状态、序列空间、窗口、缓存和定时器。\n参考规范 RFC 9293：Transmission Control Protocol RFC 5681：TCP Congestion Control RFC 6298：Computing TCP\u0026rsquo;s Retransmission Timer RFC 2018：TCP Selective Acknowledgment Options ","date":"2026-08-17T17:35:00+08:00","image":"/MyBlog/p/dpdk-userspace-stack-tcp-reliable-transmission/cover.svg","permalink":"/MyBlog/p/dpdk-userspace-stack-tcp-reliable-transmission/","title":"DPDK 用户态协议栈（三）：从 TCP 握手到可靠传输"},{"content":" 本文承接《dpdk用户态协议栈设计与实现》。本文把重点放在一个新问题上：收到数据以后，怎样在用户态构造一个符合协议的响应包并发送出去？ 为了让代码能够独立读懂，后文会从程序入口开始完整解释执行流程；网络分层等原理知识则只在影响代码理解时说明。\n一、本篇要完成什么 上一篇的程序已经可以：\n初始化 EAL、mempool 和网口； 通过 rte_eth_rx_burst() 轮询收包； 识别 Ethernet、IPv4、UDP 和 TCP； 打印报文中的地址、端口与数据。 本篇继续完成三件事：\n为网口配置 TX Queue，使程序具备发包能力； 实现 UDP Echo：收到 UDP 数据后，构造响应包并原样返回 payload； 实现最小 TCP 服务端：收到 SYN 后回复 SYN+ACK，再接收 ACK 和应用数据。 这里实现的是用于学习协议与验证收发链路的最小原型，不是可投入生产的完整协议栈。\n二、用编译期开关隔离增量代码 开发新功能时，可以先用宏把新增代码包起来：\n#define ENABLE_SEND 1 #define ENABLE_TCP 1 #if ENABLE_SEND /* TX Queue、UDP 回包等新增代码 */ #endif #if ENABLE_TCP /* TCP 状态机与 TCP 回包代码 */ #endif 这样做的价值不是“上线前把代码删掉”，而是：\n新功能出问题时，可快速回退到仅收包版本； 可以分别验证 RX、UDP TX 和 TCP 三部分； 提交 patch 时，新增逻辑的边界更加清楚。 如果功能会长期存在，最终更适合使用构建选项或运行时配置；长期散落大量 #if 会增加维护成本。\n三、能收包不等于能发包 仅配置 RX Queue 时，网卡只能供当前程序收包。要发送数据，还需要完成三步：\nconst uint16_t num_rx_queues = 1; const uint16_t num_tx_queues = ENABLE_SEND ? 1 : 0; if (rte_eth_dev_configure(port_id, num_rx_queues, num_tx_queues, \u0026amp;port_conf) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not configure port\\n\u0026#34;); } #if ENABLE_SEND struct rte_eth_txconf txq_conf = dev_info.default_txconf; txq_conf.offloads = port_conf.txmode.offloads; if (rte_eth_tx_queue_setup(port_id, 0, 512, rte_eth_dev_socket_id(port_id), \u0026amp;txq_conf) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not setup TX queue\\n\u0026#34;); } #endif if (rte_eth_dev_start(port_id) \u0026lt; 0) { rte_exit(EXIT_FAILURE, \u0026#34;Could not start port\\n\u0026#34;); } 完整顺序是：\nrte_eth_dev_configure ↓ rte_eth_rx_queue_setup / rte_eth_tx_queue_setup ↓ rte_eth_dev_start ↓ rte_eth_rx_burst / rte_eth_tx_burst rte_eth_tx_burst() 的返回值 uint16_t nb_tx = rte_eth_tx_burst(port_id, 0, \u0026amp;tx_mbuf, 1); if (nb_tx == 0) { rte_pktmbuf_free(tx_mbuf); } 返回值表示实际被 TX Queue 接收的 mbuf 数量：\n返回 1：mbuf 的所有权已交给 DPDK/驱动，应用不能立即释放或继续修改； 返回 0：该 mbuf 没有成功入队，仍由应用负责，应重试或释放。 批量发送时，只能释放返回值之后的未发送部分：\nuint16_t nb_tx = rte_eth_tx_burst(port_id, 0, tx_pkts, nb_pkts); for (uint16_t i = nb_tx; i \u0026lt; nb_pkts; i++) { rte_pktmbuf_free(tx_pkts[i]); } 这是 DPDK 发包代码中很重要的内存所有权规则。\n四、为什么把收到的 mbuf 直接发出去不是 Echo 最初的尝试是：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbufs[i], 1); 这只是把收到的原始帧再次发出。原包仍然是：\n客户端 MAC/IP/Port → 服务端 MAC/IP/Port 而响应包必须是：\n服务端 MAC/IP/Port → 客户端 MAC/IP/Port 因此至少要处理：\n协议层 响应包需要修改的内容 Ethernet 源 MAC 与目的 MAC 对调 IPv4 源 IP 与目的 IP 对调，重新计算 IPv4 头校验和 UDP 源端口与目的端口对调，重新计算 UDP 校验和 TCP 对调端口，并根据状态设置 Flags、SEQ、ACK 和校验和 以太网 FCS/CRC 通常由网卡在真正发出帧时生成，应用一般不需要在 mbuf 末尾手工追加；但 IPv4、UDP、TCP 的校验和必须由软件计算，或者明确配置硬件 checksum offload。\n五、UDP Echo 的数据路径 UDP Echo 的完整过程是：\nRX Queue 收到请求 ↓ 检查 Ethernet / IPv4 / UDP 及各层长度 ↓ 保存并对调 MAC、IP、Port ↓ 从 mempool 分配新的 TX mbuf ↓ 依次写入 Ethernet、IPv4、UDP Header ↓ 复制原 UDP payload ↓ 计算 IPv4 与 UDP checksum ↓ rte_eth_tx_burst 发送 为便于学习，草稿使用了全局变量暂存一组通信端点：\nuint8_t global_smac[RTE_ETHER_ADDR_LEN]; uint8_t global_dmac[RTE_ETHER_ADDR_LEN]; uint32_t global_sip; uint32_t global_dip; uint16_t global_sport; uint16_t global_dport; 收到请求后，对调方向：\nrte_memcpy(global_smac, eth-\u0026gt;dst_addr.addr_bytes, RTE_ETHER_ADDR_LEN); rte_memcpy(global_dmac, eth-\u0026gt;src_addr.addr_bytes, RTE_ETHER_ADDR_LEN); global_sip = ip-\u0026gt;dst_addr; global_dip = ip-\u0026gt;src_addr; global_sport = udp-\u0026gt;dst_port; global_dport = udp-\u0026gt;src_port; 这些字段本身仍保持网络字节序，写回协议头时不需要再次 hton*()。\n不要用全局变量保存真实连接 全局变量只适合单队列、单线程、一次处理一个包的教学程序。多个客户端或多个 lcore 并发时，后一包会覆盖前一包的端点信息。更合理的做法是把地址信息作为函数参数传入，TCP 则使用以四元组为 key 的连接表。\n六、构造 UDP 响应包 1. 先算清三种长度 设 UDP payload 长度为 payload_len：\nuint16_t udp_len = sizeof(struct rte_udp_hdr) + payload_len; uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + udp_len; uint16_t frame_len = sizeof(struct rte_ether_hdr) + ip_len; 三者的含义不同：\nudp-\u0026gt;dgram_len：UDP Header + UDP payload； ip-\u0026gt;total_length：IPv4 Header + IPv4 payload，不含 Ethernet Header； mbuf-\u0026gt;pkt_len/data_len：本例中的完整 Ethernet 帧长度，不含硬件生成的 FCS。 2. 分配并扩展 mbuf 更稳妥的写法是使用 rte_pktmbuf_append()，而不是只手工改长度字段：\nstruct rte_mbuf *tx_mbuf = rte_pktmbuf_alloc(mbuf_pool); if (tx_mbuf == NULL) { return -ENOMEM; } uint8_t *frame = rte_pktmbuf_append(tx_mbuf, frame_len); if (frame == NULL) { rte_pktmbuf_free(tx_mbuf); return -ENOSPC; } rte_pktmbuf_append() 会检查尾部空间，并同步更新 data_len 与 pkt_len。\n3. 写 Ethernet Header struct rte_ether_hdr *eth = (struct rte_ether_hdr *)frame; rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN); rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN); eth-\u0026gt;ether_type = rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); 不同 DPDK 版本中，成员名可能是 d_addr/s_addr 或 dst_addr/src_addr，应以本机头文件为准。\n4. 写 IPv4 Header struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); *ip = (struct rte_ipv4_hdr){0}; ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_UDP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = 0; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); version_ihl = 0x45 表示 IPv4，且头部长度为 5 × 4 = 20 字节。使用 RTE_IPV4_VHL_DEF 可读性更好。\ntotal_length 是 16 位多字节字段，写入协议头前必须转为网络字节序。\n5. 写 UDP Header 和 payload struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); udp-\u0026gt;src_port = global_sport; udp-\u0026gt;dst_port = global_dport; udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); udp-\u0026gt;dgram_cksum = 0; rte_memcpy(udp + 1, payload, payload_len); udp-\u0026gt;dgram_cksum = rte_ipv4_udptcp_cksum(ip, udp); 这里最容易写错的是复制长度。udp_len 包含 8 字节 UDP Header，因此复制 payload 时必须使用：\npayload_len = udp_len - sizeof(struct rte_udp_hdr); 不能把整个 udp_len 字节都从 udp + 1 开始复制，否则会越界读取原报文，并可能越界写入新 mbuf。\n6. 一个整理后的编码函数 static int encode_udp_echo(uint8_t *frame, const uint8_t *payload, uint16_t payload_len) { uint16_t udp_len = sizeof(struct rte_udp_hdr) + payload_len; uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + udp_len; struct rte_ether_hdr *eth = (struct rte_ether_hdr *)frame; rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, global_dmac, RTE_ETHER_ADDR_LEN); rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, global_smac, RTE_ETHER_ADDR_LEN); eth-\u0026gt;ether_type = rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); struct rte_ipv4_hdr *ip = (struct rte_ipv4_hdr *)(eth + 1); *ip = (struct rte_ipv4_hdr){0}; ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); ip-\u0026gt;time_to_live = 64; ip-\u0026gt;next_proto_id = IPPROTO_UDP; ip-\u0026gt;src_addr = global_sip; ip-\u0026gt;dst_addr = global_dip; ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); struct rte_udp_hdr *udp = (struct rte_udp_hdr *)(ip + 1); udp-\u0026gt;src_port = global_sport; udp-\u0026gt;dst_port = global_dport; udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); udp-\u0026gt;dgram_cksum = 0; rte_memcpy(udp + 1, payload, payload_len); udp-\u0026gt;dgram_cksum = rte_ipv4_udptcp_cksum(ip, udp); return 0; } 七、安全取得 UDP payload 草稿中使用：\nprintf(\u0026#34;udp: %s\\n\u0026#34;, (char *)(udp + 1)); 这只能用于确保 payload 末尾带 \\0 的演示数据。网络报文是带长度的二进制数据，不保证是 C 字符串。\n在不考虑 IPv4 Options 的最小版本中：\nuint16_t udp_len = rte_be_to_cpu_16(udp-\u0026gt;dgram_len); if (udp_len \u0026lt; sizeof(struct rte_udp_hdr)) { /* malformed packet */ } uint16_t payload_len = udp_len - sizeof(struct rte_udp_hdr); uint8_t *payload = (uint8_t *)(udp + 1); printf(\u0026#34;udp: %.*s\\n\u0026#34;, (int)payload_len, (char *)payload); 真正处理报文前，还要结合 rte_pktmbuf_pkt_len(mbuf) 检查 Ethernet、IP、UDP 声明的长度是否落在 mbuf 实际数据范围内。需要记住：构造包和解析包都必须以显式长度为边界。\n打印 IP 和端口 struct in_addr addr = { .s_addr = ip-\u0026gt;src_addr }; printf(\u0026#34;sip %s:%u --\u0026gt; \u0026#34;, inet_ntoa(addr), rte_be_to_cpu_16(udp-\u0026gt;src_port)); addr.s_addr = ip-\u0026gt;dst_addr; printf(\u0026#34;dip %s:%u\\n\u0026#34;, inet_ntoa(addr), rte_be_to_cpu_16(udp-\u0026gt;dst_port)); inet_ntoa() 使用内部静态缓冲区，不适合并发代码；工程中优先使用 inet_ntop()。\n八、从 UDP 过渡到 TCP：变化不只是 Header UDP 收到一个数据报就可以立即生成一个独立响应。TCP 则必须维护连接状态，因为一个 TCP 报文的正确响应取决于此前发生过什么。\n最小服务端路径为：\n客户端 DPDK 服务端 | -------- SYN, seq=x --------\u0026gt; | | \u0026lt;--- SYN+ACK, seq=y, ack=x+1 -| | -------- ACK, ack=y+1 ------\u0026gt; | | | ESTABLISHED | ------ PSH+ACK, data --------\u0026gt; | 草稿定义了完整 TCP 状态枚举：\ntypedef enum { USTACK_TCP_STATUS_CLOSED = 0, USTACK_TCP_STATUS_LISTEN, USTACK_TCP_STATUS_SYN_RCVD, USTACK_TCP_STATUS_SYN_SENT, USTACK_TCP_STATUS_ESTABLISHED, USTACK_TCP_STATUS_FIN_WAIT_1, USTACK_TCP_STATUS_FIN_WAIT_2, USTACK_TCP_STATUS_CLOSING, USTACK_TCP_STATUS_TIME_WAIT, USTACK_TCP_STATUS_CLOSE_WAIT, USTACK_TCP_STATUS_LAST_ACK } ustack_tcp_status_t; 当前代码实际只实现了：\nLISTEN → SYN_RCVD → ESTABLISHED 因此准确的描述是“完成服务端三次握手的最小实验”，而不是“已经实现 TCP 协议栈”。重传、乱序、滑动窗口、拥塞控制、RST、FIN 四次挥手、TIME_WAIT、TCP Options 和多连接管理都还没有实现。\n九、TCP 的 SEQ 与 ACK 应该怎样理解 收到客户端 SYN：\nclient.seq = x 服务端回复 SYN+ACK：\nserver.seq = y server.ack = x + 1 SYN 虽然不携带应用数据，但会占用一个序列号，所以确认号是 x + 1。FIN 同样占用一个序列号。\n对于普通数据段：\n下一确认号 = 当前 seq + TCP payload 长度 如果同一段还带 SYN 或 FIN，还应分别再加 1。\n草稿中的：\ntcp-\u0026gt;sent_seq = htonl(12345); tcp-\u0026gt;recv_ack = htonl(global_seqnum + 1); 足以演示 SYN+ACK，但 12345 应被视为教学用的初始序列号。真实实现需要为每条连接保存本端/对端序列号，并校验收到的 ACK 是否真正确认了自己发送的 SYN 或数据。\n十、构造最小 SYN+ACK TCP 响应与 UDP 的 Ethernet、IPv4 部分基本相同；区别在 TCP Header：\nstruct rte_tcp_hdr *tcp = (struct rte_tcp_hdr *)(ip + 1); *tcp = (struct rte_tcp_hdr){0}; tcp-\u0026gt;src_port = global_sport; tcp-\u0026gt;dst_port = global_dport; tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(server_isn); tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(client_seq + 1); tcp-\u0026gt;data_off = (sizeof(struct rte_tcp_hdr) / 4) \u0026lt;\u0026lt; 4; tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; tcp-\u0026gt;rx_win = rte_cpu_to_be_16(TCP_INIT_WINDOW); tcp-\u0026gt;cksum = 0; tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 几个容易忽略的点：\ndata_off 的高 4 位表示 TCP Header 占几个 32 位字；基础头 20 字节，所以值为 5 \u0026lt;\u0026lt; 4，即 0x50； rx_win 是多字节字段，应使用网络字节序； 计算 checksum 前，checksum 字段必须先清零； struct rte_tcp_hdr 最好整体清零，避免未初始化的 cksum、tcp_urp 等字段混入报文； IPv4 total_length 必须覆盖 IPv4 Header、TCP Header 和 TCP payload。 为什么 UDP 头有长度，而 TCP 头没有 UDP 自身以数据报为边界，因此 Header 中有 dgram_len：\nUDP 长度 = UDP Header + UDP payload TCP 是字节流协议，段长度可以从 IPv4 总长度推导：\nTCP 段长度 = IP total_length - IP Header 长度 TCP payload 长度 = TCP 段长度 - TCP Header 长度 对应代码：\nuint16_t ip_hdr_len = (ip-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; uint16_t tcp_hdr_len = (tcp-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; uint16_t ip_total_len = rte_be_to_cpu_16(ip-\u0026gt;total_length); uint16_t tcp_payload_len = ip_total_len - ip_hdr_len - tcp_hdr_len; 因此，TCP 的确认号并不是“长度字段”，但它会随着已经确认的字节数向前推进，体现了 TCP 字节流的位置。\n十一、最小 TCP 状态处理 1. LISTEN 收到 SYN if ((flags \u0026amp; RTE_TCP_SYN_FLAG) \u0026amp;\u0026amp; state == USTACK_TCP_STATUS_LISTEN) { client_next_seq = rte_be_to_cpu_32(tcp-\u0026gt;sent_seq) + 1; send_syn_ack(...); state = USTACK_TCP_STATUS_SYN_RCVD; } 2. SYN_RCVD 收到有效 ACK 不能只检查 ACK 位，还应验证确认号：\nif ((flags \u0026amp; RTE_TCP_ACK_FLAG) \u0026amp;\u0026amp; state == USTACK_TCP_STATUS_SYN_RCVD \u0026amp;\u0026amp; rte_be_to_cpu_32(tcp-\u0026gt;recv_ack) == server_isn + 1) { state = USTACK_TCP_STATUS_ESTABLISHED; } 3. ESTABLISHED 收到数据 PSH 只是提示接收端尽快把数据交给应用，不是“存在 payload”的唯一判断条件。更可靠的判断是计算 tcp_payload_len \u0026gt; 0：\nuint16_t payload_len = get_tcp_payload_len(ip, tcp); if (state == USTACK_TCP_STATUS_ESTABLISHED \u0026amp;\u0026amp; payload_len \u0026gt; 0) { const uint8_t *payload = (const uint8_t *)tcp + tcp_hdr_len; printf(\u0026#34;tcp data: %.*s\\n\u0026#34;, (int)payload_len, (const char *)payload); /* 回复 ACK 时：ack = peer_seq + payload_len */ } 草稿能够打印 TCP 数据，但还没有为数据段回复 ACK，也没有处理 FIN；一些客户端因此会持续重传或无法正常关闭连接。\n十二、从草稿中提炼出的典型 Bug 1. IPv4 地址只复制了 2 字节 错误版本：\nrte_memcpy(\u0026amp;global_sip, \u0026amp;ip-\u0026gt;dst_addr, sizeof(uint16_t)); IPv4 地址是 32 位，应复制 sizeof(uint32_t)，或者直接赋值：\nglobal_sip = ip-\u0026gt;dst_addr; global_dip = ip-\u0026gt;src_addr; 2. 把 UDP 总长度当成 payload 长度 错误思路：\nrte_memcpy(udp + 1, data, udp_len); 正确关系：\npayload_len = udp_len - sizeof(struct rte_udp_hdr); rte_memcpy(udp + 1, data, payload_len); 3. 分配了新 mbuf，却传错发送指针 错误版本：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbufs, 1); 应发送刚构造的单个 mbuf：\nrte_eth_tx_burst(port_id, 0, \u0026amp;mbuf, 1); \u0026amp;mbufs 是整个 RX 指针数组的地址，语义也不正确。\n4. 多字节字段漏做字节序转换 典型字段包括：\nip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(seq); tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(ack); tcp-\u0026gt;rx_win = rte_cpu_to_be_16(window); MAC 地址和 payload 是字节数组，不需要转换。\n5. 不检查 TX 返回值 TX Queue 满时，rte_eth_tx_burst() 可以少发甚至不发。未成功发送的 mbuf 若不释放，会产生内存泄漏。\n6. 只看 Flags，不校验状态与序列号 “收到带 ACK 标志的包”不等于三次握手完成。至少还要确认当前状态是 SYN_RCVD，且 ACK 值为服务端初始序列号加 1。\n十三、完整代码逐行讲解 这一节按照程序真正的运行顺序，从头文件开始解释，直到收到、解析、构造并发送一个包。代码前面的数字只是讲解用行号，不是源代码的一部分。\n1. 头文件分别提供什么 01 #include \u0026lt;stdio.h\u0026gt; 02 #include \u0026lt;stdint.h\u0026gt; 03 #include \u0026lt;errno.h\u0026gt; 04 #include \u0026lt;arpa/inet.h\u0026gt; 05 06 #include \u0026lt;rte_eal.h\u0026gt; 07 #include \u0026lt;rte_ethdev.h\u0026gt; 08 #include \u0026lt;rte_mbuf.h\u0026gt; 09 #include \u0026lt;rte_ether.h\u0026gt; 10 #include \u0026lt;rte_ip.h\u0026gt; 11 #include \u0026lt;rte_udp.h\u0026gt; 12 #include \u0026lt;rte_tcp.h\u0026gt; 第 1 行：提供 printf()。 第 2 行：提供 uint8_t、uint16_t、uint32_t 等定长整数类型。网络协议字段有固定宽度，因此比普通 int 更合适。 第 3 行：提供 ENOMEM、ENOSPC 等错误码。 第 4 行：提供 inet_ntop()、htons() 等网络地址与字节序函数。 第 6 行：提供 rte_eal_init()、rte_exit() 等 DPDK 运行环境接口。 第 7 行：提供网口、RX/TX Queue 和 rte_eth_rx_burst()、rte_eth_tx_burst()。 第 8 行：提供 mempool、mbuf 分配、释放和数据地址转换接口。 第 9～12 行：分别提供 Ethernet、IPv4、UDP、TCP Header 结构体和协议常量。 #include 的作用是把函数声明、结构体定义和宏定义提供给当前 .c 文件。它不会替代链接步骤；编译时仍要通过 DPDK 的 pkg-config 参数链接对应库。\n2. 全局端口、mbuf 数量与 Burst 大小 01 static uint16_t global_portid = 0; 02 03 #define NUM_MBUFS 4096 04 #define BURST_SIZE 128 第 1 行：选择 DPDK 端口 0。它表示 DPDK 枚举得到的端口编号，不一定等于 Linux 中的网卡名称或 PCI 地址。 static：这里表示变量只在当前源文件内可见，减少与其他文件同名变量冲突的机会。 第 3 行：mempool 预备 4096 个 mbuf。RX 和新建的 TX 包都会消耗池中的对象。 第 4 行：一次 rte_eth_rx_burst() 最多取回 128 个 mbuf 指针。 NUM_MBUFS 是整个对象池规模，BURST_SIZE 是一次批量操作的上限，两者不是同一概念。\n3. 功能开关与发送端点 01 #define ENABLE_SEND 1 02 #define ENABLE_TCP 1 03 #define TCP_INIT_WINDOW 14600 04 05 #if ENABLE_SEND 06 uint8_t global_smac[RTE_ETHER_ADDR_LEN]; 07 uint8_t global_dmac[RTE_ETHER_ADDR_LEN]; 08 uint32_t global_sip; 09 uint32_t global_dip; 10 uint16_t global_sport; 11 uint16_t global_dport; 12 #endif 第 1 行：控制是否编译发包代码。关闭后可退回仅收包版本。 第 2 行：控制是否编译 TCP 实验代码，可以只验证 UDP。 第 3 行：定义 SYN+ACK 中通告给客户端的接收窗口大小。 第 5～12 行：只有启用发送功能时才定义响应端点。 第 6～7 行：分别保存响应包的源 MAC 与目的 MAC。 第 8～9 行：IPv4 地址是 32 位，因此类型必须是 uint32_t。 第 10～11 行：TCP/UDP 端口是 16 位。 这些变量保存的地址和端口直接来自收到的协议头，所以仍是网络字节序。只有打印或参与主机侧数值计算时，才需要 rte_be_to_cpu_16()、rte_be_to_cpu_32()。\n4. 端口默认配置 01 static const struct rte_eth_conf port_conf_default = { 02 .rxmode = { 03 .max_rx_pkt_len = RTE_ETHER_MAX_LEN 04 } 05 }; 第 1 行：定义一个网口配置结构体，并用 const 表示运行中不修改它。 .rxmode = { ... }：这是 C 语言的指定初始化器，只填写 rxmode 成员，其余成员自动初始化为 0。 第 3 行：把最大接收包长设为普通 Ethernet 帧允许的范围。 不同 DPDK 版本的 struct rte_eth_conf 字段可能变化。如果本机版本提示 max_rx_pkt_len 不存在，应检查安装版本的头文件和示例，而不是强行照抄字段名。\n5. 查询可用端口和设备默认能力 01 static int ustack_init_port(struct rte_mempool *mbuf_pool) 02 { 03 uint16_t nb_ports = rte_eth_dev_count_avail(); 04 05 if (nb_ports == 0) { 06 rte_exit(EXIT_FAILURE, 07 \u0026#34;No supported Ethernet device found\\n\u0026#34;); 08 } 09 10 if (!rte_eth_dev_is_valid_port(global_portid)) { 11 rte_exit(EXIT_FAILURE, \u0026#34;Invalid port id\\n\u0026#34;); 12 } 13 14 struct rte_eth_dev_info dev_info; 15 int ret = rte_eth_dev_info_get(global_portid, \u0026amp;dev_info); 16 if (ret != 0) { 17 rte_exit(EXIT_FAILURE, 18 \u0026#34;Cannot get device info: %d\\n\u0026#34;, ret); 19 } 第 1 行：函数接收 mempool 指针，因为 RX Queue 需要知道从哪个池获取接收缓冲区。 第 3 行：查询当前由 DPDK 管理且可用的 Ethernet 端口数量。 第 5～8 行：一个可用端口都没有时直接退出。常见原因包括网卡没有绑定到合适驱动、权限不足或 EAL 参数错误。 第 10 行：即使系统存在端口，也要确认准备使用的 global_portid 有效。 第 14 行：声明设备信息结构体，用于接收驱动默认配置和网卡能力。 第 15 行：读取 0 号端口的信息。 第 16～18 行：检查 API 返回值；不能在查询失败后继续使用未初始化的 dev_info。 6. 配置 RX Queue 与 TX Queue 的数量 01 const uint16_t num_rx_queues = 1; 02 03 #if ENABLE_SEND 04 const uint16_t num_tx_queues = 1; 05 #else 06 const uint16_t num_tx_queues = 0; 07 #endif 08 09 rte_eth_dev_configure(global_portid, 10 num_rx_queues, 11 num_tx_queues, 12 \u0026amp;port_conf_default); 13 14 #if ENABLE_SEND 15 struct rte_eth_txconf txq_conf = dev_info.default_txconf; 16 txq_conf.offloads = port_conf_default.txmode.offloads; 17 18 if (rte_eth_tx_queue_setup(global_portid, 19 0, 20 512, 21 rte_eth_dev_socket_id(global_portid), 22 \u0026amp;txq_conf) \u0026lt; 0) { 23 rte_exit(EXIT_FAILURE, \u0026#34;Could not setup TX queue\\n\u0026#34;); 24 } 25 #endif 第 1 行：仍然只配置一个 RX Queue。 第 3～7 行：启用发送时创建一个 TX Queue，否则把 TX Queue 数量设为 0。 第 9～12 行：网口配置阶段必须提前声明 RX/TX Queue 的数量。 第 15 行：从设备能力信息中复制驱动提供的默认 TX 配置，避免凭空构造不兼容的参数。 第 16 行：把端口级 TX offload 配置同步给队列。草稿整理前曾写成 rxmode.offloads，此处应与 TX 配置对应。 第 18 行：开始配置 TX Queue。 第 19 行：队列编号为 0，因为当前只创建一个发送队列。 第 20 行：TX 描述符数量为 512，它描述队列容量，不是单次最多只能发送 512 个包。 第 21 行：让队列内存尽量位于网卡所属的 NUMA Socket。 第 22 行：传入刚才复制并调整的 TX 配置。 第 23 行：配置失败就退出，避免程序进入一个必然无法发送的循环。 7. 配置 RX Queue 01 if (rte_eth_rx_queue_setup(global_portid, 02 0, 03 128, 04 rte_eth_dev_socket_id(global_portid), 05 NULL, 06 mbuf_pool) \u0026lt; 0) { 07 rte_exit(EXIT_FAILURE, \u0026#34;Could not setup RX queue\\n\u0026#34;); 08 } 第 1 行：为指定网口建立接收队列。 第 2 行：队列编号为 0。 第 3 行：配置 128 个 RX 描述符。描述符用于让网卡和驱动追踪接收缓冲区，不等于只创建 128 个 mbuf。 第 4 行：选择网卡所在 NUMA Socket。 第 5 行：传 NULL 表示使用驱动默认 RX Queue 配置。 第 6 行：把前面创建的 mempool 交给 RX Queue。网卡收到包后，驱动会使用池中的 mbuf 承载数据。 第 7 行：队列创建失败就退出。 8. 启动端口并返回 01 if (rte_eth_dev_start(global_portid) \u0026lt; 0) { 02 rte_exit(EXIT_FAILURE, \u0026#34;Could not start port\\n\u0026#34;); 03 } 04 05 return 0; 06 } 第 1 行：前面的 configure 和 queue setup 只是准备配置，这里才真正启动设备。 第 2 行：启动失败不能进入收发循环。 第 5 行：返回 0 表示端口初始化成功。 第 6 行：结束 ustack_init_port() 函数。 9. main() 的参数与 EAL 初始化 01 int main(int argc, char **argv) 02 { 03 int ret = rte_eal_init(argc, argv); 04 if (ret \u0026lt; 0) { 05 rte_exit(EXIT_FAILURE, \u0026#34;Error with EAL init\\n\u0026#34;); 06 } 第 1 行：argc 是命令行参数数量，argv 是字符串指针数组。 第 3 行：初始化 DPDK EAL。它会处理 lcore、内存、设备等 DPDK 参数。 返回负数表示初始化失败。 返回非负数表示 EAL 消耗掉的命令行参数数量。如果程序还要解析自己的参数，可以使用 argc -= ret; argv += ret; 跳过 EAL 参数。 第 4～5 行：初始化失败时打印错误并结束进程。 EAL 必须先初始化，后面创建 mempool、查询端口和配置设备才有可用的 DPDK 运行环境。\n10. 创建 mbuf pool 07 struct rte_mempool *mbuf_pool = 08 rte_pktmbuf_pool_create(\u0026#34;mbuf_pool\u0026#34;, 09 NUM_MBUFS, 10 0, 11 0, 12 RTE_MBUF_DEFAULT_BUF_SIZE, 13 rte_socket_id()); 14 15 if (mbuf_pool == NULL) { 16 rte_exit(EXIT_FAILURE, 17 \u0026#34;Could not create mbuf pool\\n\u0026#34;); 18 } 第 7 行：声明 mempool 指针，用来保存创建结果。 第 8 行：池名称用于 DPDK 内部查找和调试，同一进程中应保持唯一。 第 9 行：池中创建 NUM_MBUFS 个 mbuf 对象。 第 10 行：per-lcore cache 大小设为 0，逻辑简单，但性能实验中通常会选择合适缓存值。 第 11 行：每个 mbuf 不添加额外的应用私有区。 第 12 行：每个 mbuf 的数据缓冲区使用 DPDK 默认大小。 第 13 行：在当前执行 lcore 所在 NUMA Socket 分配池内存。 第 15～18 行：创建失败时不能继续配置 RX Queue。 11. 初始化网口并进入无限循环 19 ustack_init_port(mbuf_pool); 20 21 while (1) { 22 struct rte_mbuf *mbufs[BURST_SIZE] = {0}; 第 19 行：把刚创建的 mempool 交给端口初始化函数，并建立 RX/TX Queue。 第 21 行：DPDK 常使用轮询模式，所以程序持续运行，不断检查 RX Queue。 第 22 行：在栈上创建一个数组，用来保存本次批量取回的 mbuf 指针。数组中不是报文数据本身，而是指向 mbuf 的指针。 = {0} 会把所有数组元素初始化为空指针；虽然接下来有效元素会被 API 覆盖，但初始化有利于调试。 12. 从 RX Queue 批量取包 23 uint16_t num_recvd = 24 rte_eth_rx_burst(global_portid, 25 0, 26 mbufs, 27 BURST_SIZE); 28 29 for (uint16_t i = 0; i \u0026lt; num_recvd; i++) { 30 struct rte_mbuf *rx_mbuf = mbufs[i]; 31 uint32_t frame_len = rte_pktmbuf_pkt_len(rx_mbuf); 第 23～27 行：从 0 号端口、0 号 RX Queue 最多取 BURST_SIZE 个包。 返回 0 表示当前没有包，不是错误；while 会立即进入下一轮轮询。 返回值不会大于传入的 BURST_SIZE，所以原草稿中的 num_recvd \u0026gt; BURST_SIZE 棬查没有实际意义。 第 29 行：只遍历 API 确认有效的前 num_recvd 个元素。 第 30 行：为当前包取一个更易读的局部变量名。 第 31 行：读取整个包的实际字节数，后面定位 Header 前必须先检查长度。 13. 检查并定位 Ethernet Header 32 if (frame_len \u0026lt; sizeof(struct rte_ether_hdr)) { 33 rte_pktmbuf_free(rx_mbuf); 34 continue; 35 } 36 37 struct rte_ether_hdr *ethhdr = 38 rte_pktmbuf_mtod(rx_mbuf, 39 struct rte_ether_hdr *); 40 41 if (ethhdr-\u0026gt;ether_type != 42 rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { 43 rte_pktmbuf_free(rx_mbuf); 44 continue; 45 } 第 32 行：报文连一个 Ethernet Header 都放不下时，不能继续强制转换并读取字段。 第 33 行：当前 mbuf 没有交给 TX，应用仍拥有它，因此需要释放。 第 34 行：跳过当前包，继续处理 Burst 中的下一个包。 第 37～39 行：rte_pktmbuf_mtod() 取得 mbuf 数据起始地址，并转换为 Ethernet Header 指针。 第 41～42 行：EtherType 等于 IPv4 才进入本实验的 IPv4 处理路径。 第 43～44 行：非 IPv4 包当前不处理，但仍必须释放 mbuf。 rte_pktmbuf_mtod() 只是做地址取得与类型转换，不会自动校验数据是否足够长，所以第 32 行不能省略。\n14. 检查并定位 IPv4 Header 46 uint32_t min_ipv4_frame = 47 sizeof(struct rte_ether_hdr) + 48 sizeof(struct rte_ipv4_hdr); 49 50 if (frame_len \u0026lt; min_ipv4_frame) { 51 rte_pktmbuf_free(rx_mbuf); 52 continue; 53 } 54 55 struct rte_ipv4_hdr *iphdr = 56 rte_pktmbuf_mtod_offset( 57 rx_mbuf, 58 struct rte_ipv4_hdr *, 59 sizeof(struct rte_ether_hdr)); 60 61 uint16_t ip_hdr_len = 62 (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; 63 64 if (ip_hdr_len \u0026lt; sizeof(struct rte_ipv4_hdr) || 65 frame_len \u0026lt; sizeof(struct rte_ether_hdr) + 66 ip_hdr_len) { 67 rte_pktmbuf_free(rx_mbuf); 68 continue; 69 } 第 46～48 行：计算 Ethernet Header 加最小 IPv4 Header 需要多少字节。 第 50～53 行：实际帧比这个值短时直接丢弃。 第 55～59 行：从 mbuf 数据起点偏移一个 Ethernet Header，取得 IPv4 Header 地址。 第 61～62 行：IPv4 IHL 位于 version_ihl 的低 4 位，单位是 4 字节。 第 64 行：IHL 小于 20 字节不合法。 第 65～66 行：实际帧必须能容纳 IHL 声明的完整 IPv4 Header。 第 67～68 行：格式不合法就释放并跳过。 当前示例默认 Header 位于 mbuf 的第一个连续数据段。若接收的是 multi-segment mbuf，直接取指针前还要确保所需字节连续，或使用 rte_pktmbuf_read()。\n15. 根据 IHL 定位 UDP 或 TCP Header 70 uint8_t *l4 = (uint8_t *)iphdr + ip_hdr_len; 71 72 if (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { 73 if (frame_len \u0026lt; 74 sizeof(struct rte_ether_hdr) + ip_hdr_len + 75 sizeof(struct rte_udp_hdr)) { 76 rte_pktmbuf_free(rx_mbuf); 77 continue; 78 } 79 80 struct rte_udp_hdr *udphdr = 81 (struct rte_udp_hdr *)l4; 82 /* 进入 UDP Echo 处理 */ 83 84 } else if (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { 85 if (frame_len \u0026lt; 86 sizeof(struct rte_ether_hdr) + ip_hdr_len + 87 sizeof(struct rte_tcp_hdr)) { 88 rte_pktmbuf_free(rx_mbuf); 89 continue; 90 } 91 92 struct rte_tcp_hdr *tcphdr = 93 (struct rte_tcp_hdr *)l4; 94 /* 进入 TCP 状态处理 */ 95 } 第 70 行：不能总用 iphdr + 1 找四层 Header，因为 IPv4 可能带 Options；这里使用前面算出的真实 IHL。 第 72 行：next_proto_id 表示 IPv4 payload 使用哪种上层协议。 第 73～78 行：读取 UDP Header 之前，确认帧中至少有 8 字节 UDP Header。 第 80～81 行：把四层起点解释为 UDP Header。 第 84 行：如果不是 UDP，再检查是否为 TCP。 第 85～90 行：TCP 基础 Header 至少 20 字节，读取前同样检查实际长度。 第 92～93 行：把四层起点解释为 TCP Header。 到这里，程序才安全地取得 UDP 或 TCP Header。下面开始解释各协议的响应构造。\n16. 收到 UDP 包后保存响应方向 01 if (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { 02 struct rte_udp_hdr *udphdr = 03 (struct rte_udp_hdr *)((uint8_t *)iphdr + ip_hdr_len); 04 05 rte_memcpy(global_smac, 06 ethhdr-\u0026gt;dst_addr.addr_bytes, 07 RTE_ETHER_ADDR_LEN); 08 rte_memcpy(global_dmac, 09 ethhdr-\u0026gt;src_addr.addr_bytes, 10 RTE_ETHER_ADDR_LEN); 11 12 global_sip = iphdr-\u0026gt;dst_addr; 13 global_dip = iphdr-\u0026gt;src_addr; 14 global_sport = udphdr-\u0026gt;dst_port; 15 global_dport = udphdr-\u0026gt;src_port; 第 1 行：只进入 UDP 分支。 第 2～3 行：把 IPv4 Header 起点加上刚计算出的 ip_hdr_len，定位 UDP Header；这样 IPv4 带 Options 时也不会找错位置。 第 5～7 行：请求包的目的 MAC 是本机 MAC，它应成为响应包的源 MAC。 第 8～10 行：请求包的源 MAC 是客户端 MAC，它应成为响应包的目的 MAC。 第 12～13 行：用直接赋值对调 IPv4 地址，避免误写复制长度。 第 14～15 行：对调 UDP 端口，使响应发回请求客户端。 这里的“对调”不是在原协议头中原地交换，而是先保存响应方向，稍后写入一个新的 TX mbuf。\n17. 计算收到的 UDP 数据长度 16 uint16_t udp_len = 17 rte_be_to_cpu_16(udphdr-\u0026gt;dgram_len); 18 19 if (udp_len \u0026lt; sizeof(struct rte_udp_hdr)) { 20 rte_pktmbuf_free(mbufs[i]); 21 continue; 22 } 23 24 uint16_t payload_len = 25 udp_len - sizeof(struct rte_udp_hdr); 26 27 uint8_t *payload = (uint8_t *)(udphdr + 1); 28 uint16_t ip_len = 29 sizeof(struct rte_ipv4_hdr) + udp_len; 30 uint16_t frame_len = 31 sizeof(struct rte_ether_hdr) + ip_len; 第 16～17 行：从 UDP Header 读取总长度，并转换为主机字节序，方便做减法和比较。 第 19 行：合法 UDP 长度至少为 8 字节，即一个 UDP Header。 第 20 行：这个示例假定当前分支负责释放收到的 mbuf。若外层统一释放，此处不能再次释放。 第 24～25 行：UDP payload 长度等于 UDP 总长度减去 UDP Header。 第 27 行：udphdr + 1 跨过一个完整的 UDP Header，指向 payload 起始位置。 第 28～29 行：IPv4 总长度由 IPv4 Header 和整个 UDP 数据报组成。 第 30～31 行：完整帧长度再加上 Ethernet Header。 在使用 UDP Header 声明的长度前，还必须确认它没有超过 IPv4 total_length 和 RX mbuf 的实际数据范围。前面的 Header 存在性检查只能证明 UDP Header 本身可读，不能证明对方声明的整个 payload 都真实存在。\n18. 为响应包分配 TX mbuf 32 struct rte_mbuf *tx_mbuf = 33 rte_pktmbuf_alloc(mbuf_pool); 34 35 if (tx_mbuf == NULL) { 36 printf(\u0026#34;rte_pktmbuf_alloc failed\\n\u0026#34;); 37 continue; 38 } 39 40 uint8_t *frame = 41 rte_pktmbuf_append(tx_mbuf, frame_len); 42 43 if (frame == NULL) { 44 rte_pktmbuf_free(tx_mbuf); 45 continue; 46 } 第 32～33 行：从创建好的 mempool 中取一个 mbuf，用于保存响应包。 第 35 行：mempool 耗尽时分配会失败，不能继续解引用空指针。 第 40～41 行：在 mbuf 尾部追加 frame_len 字节，并取得可写的数据地址。 第 41 行：rte_pktmbuf_append() 同时更新 data_len 和 pkt_len，比手工写两个长度字段更安全。 第 43～45 行：如果 mbuf 的可用尾部空间不足，释放刚分配的 mbuf。 这里选择“新建 TX mbuf”，优点是 RX 包保持不变，逻辑直观。后续追求性能时，也可以在满足可写、连续性和所有权条件的前提下原地改写 RX mbuf 再发送。\n19. 逐层写入 UDP 响应包 47 struct rte_ether_hdr *eth = 48 (struct rte_ether_hdr *)frame; 49 50 rte_memcpy(eth-\u0026gt;dst_addr.addr_bytes, 51 global_dmac, 52 RTE_ETHER_ADDR_LEN); 53 rte_memcpy(eth-\u0026gt;src_addr.addr_bytes, 54 global_smac, 55 RTE_ETHER_ADDR_LEN); 56 eth-\u0026gt;ether_type = 57 rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4); 第 47～48 行：把帧首地址解释成 Ethernet Header。 第 50～52 行：写目的 MAC，即原请求的源 MAC。 第 53～55 行：写源 MAC，即原请求的目的 MAC。 第 56～57 行：声明 Ethernet payload 是 IPv4。EtherType 是 16 位字段，要使用网络字节序。 接着写 IPv4 Header：\n58 struct rte_ipv4_hdr *ip = 59 (struct rte_ipv4_hdr *)(eth + 1); 60 *ip = (struct rte_ipv4_hdr){0}; 61 62 ip-\u0026gt;version_ihl = RTE_IPV4_VHL_DEF; 63 ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); 64 ip-\u0026gt;time_to_live = 64; 65 ip-\u0026gt;next_proto_id = IPPROTO_UDP; 66 ip-\u0026gt;src_addr = global_sip; 67 ip-\u0026gt;dst_addr = global_dip; 68 ip-\u0026gt;hdr_checksum = 0; 69 ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); 第 58～59 行：eth + 1 跨过 Ethernet Header，得到 IPv4 Header 地址。 第 60 行：先把整个 Header 清零，避免未初始化字段影响报文和 checksum。 第 62 行：写入 IPv4 与 20 字节基础头长度。 第 63 行：IPv4 总长度不包含 Ethernet Header。 第 64 行：TTL 设置为 64。 第 65 行：声明上层协议为 UDP。 第 66～67 行：写入已经对调好的源/目的 IPv4 地址。 第 68 行：计算 IPv4 Header checksum 前先把 checksum 字段清零。 第 69 行：计算并写入新的 IPv4 Header checksum。 最后写 UDP Header 和 payload：\n70 struct rte_udp_hdr *udp = 71 (struct rte_udp_hdr *)(ip + 1); 72 73 udp-\u0026gt;src_port = global_sport; 74 udp-\u0026gt;dst_port = global_dport; 75 udp-\u0026gt;dgram_len = rte_cpu_to_be_16(udp_len); 76 udp-\u0026gt;dgram_cksum = 0; 77 78 rte_memcpy(udp + 1, payload, payload_len); 79 udp-\u0026gt;dgram_cksum = 80 rte_ipv4_udptcp_cksum(ip, udp); 第 70～71 行：基础 IPv4 Header 后紧跟 UDP Header。 第 73～74 行：写入对调后的端口；它们已经是网络字节序。 第 75 行：UDP 长度包括 UDP Header 和 UDP payload。 第 76 行：计算 UDP checksum 前先清零。 第 78 行：只复制 payload_len，不能复制包含 UDP Header 的 udp_len。 第 79～80 行：使用 IPv4 伪首部、UDP Header 和 payload 计算 UDP checksum。 20. 发送 UDP 响应并处理所有权 81 uint16_t nb_tx = 82 rte_eth_tx_burst(global_portid, 83 0, 84 \u0026amp;tx_mbuf, 85 1); 86 87 if (nb_tx == 0) { 88 rte_pktmbuf_free(tx_mbuf); 89 } 90 } 第 81～85 行：请求 0 号 TX Queue 发送一个 mbuf。 第 84 行：参数必须是 \u0026amp;tx_mbuf，即“mbuf 指针数组的首地址”。只有一个包时，单个指针的地址可以充当长度为 1 的数组。 第 85 行：本次希望发送 1 个包。 第 87 行：若返回 0，说明驱动没有接管这个 mbuf。 第 88 行：发送失败时所有权仍属于应用，所以由应用释放。 若返回 1，不能在这里释放；后续由驱动在发送完成后回收。 外层还必须释放没有转交给 TX 的 RX mbuf。若选择原地改写 RX mbuf 并成功发送，则该 RX mbuf 的所有权已经交给 TX，不能再按接收包释放一次。\n21. TCP 状态与每条连接需要保存的数据 草稿中使用一个全局状态：\n01 uint8_t tcp_status = USTACK_TCP_STATUS_LISTEN; 02 uint32_t global_seqnum; 03 uint32_t global_acknum; 04 uint32_t server_isn = 12345; 第 1 行：服务端从监听状态开始。 第 2 行：保存收到报文的 SEQ，转换为主机字节序后便于加减。 第 3 行：保存收到报文的 ACK。 第 4 行：教学代码使用固定初始序列号；真实实现应生成合适的 ISN。 这些字段实际上都属于一条 TCP 连接。支持多个客户端时，应把它们放入连接控制块：\nstruct tcp_control_block { uint32_t local_ip; uint32_t remote_ip; uint16_t local_port; uint16_t remote_port; uint32_t snd_nxt; uint32_t rcv_nxt; ustack_tcp_status_t state; }; 前四项构成连接四元组，收到 TCP 包后用四元组查找对应状态，而不是共享一个全局 tcp_status。\n22. 收到 TCP 包并读取关键字段 01 } else if (iphdr-\u0026gt;next_proto_id == IPPROTO_TCP) { 02 struct rte_tcp_hdr *tcphdr = 03 (struct rte_tcp_hdr *)(iphdr + 1); 04 05 global_sip = iphdr-\u0026gt;dst_addr; 06 global_dip = iphdr-\u0026gt;src_addr; 07 global_sport = tcphdr-\u0026gt;dst_port; 08 global_dport = tcphdr-\u0026gt;src_port; 09 10 uint8_t flags = tcphdr-\u0026gt;tcp_flags; 11 uint32_t peer_seq = 12 rte_be_to_cpu_32(tcphdr-\u0026gt;sent_seq); 13 uint32_t peer_ack = 14 rte_be_to_cpu_32(tcphdr-\u0026gt;recv_ack); 第 1 行：进入 TCP 分支。 第 2～3 行：教学版本仍假设 IPv4 Header 固定为 20 字节。 第 5～8 行：对调 IP 和端口，准备构造服务端响应。 第 10 行：TCP Flags 只有一个字节，不需要字节序转换。 第 11～12 行：SEQ 是 32 位字段，转换为主机字节序后保存。 第 13～14 行：ACK 同样转换为主机字节序。 TCP 响应同样需要保存 MAC 方向：\nrte_memcpy(global_smac, ethhdr-\u0026gt;dst_addr.addr_bytes, RTE_ETHER_ADDR_LEN); rte_memcpy(global_dmac, ethhdr-\u0026gt;src_addr.addr_bytes, RTE_ETHER_ADDR_LEN); 第一段把收到包的目的 MAC 保存为响应源 MAC；第二段把收到包的源 MAC 保存为响应目的 MAC。\n23. LISTEN 状态收到 SYN 15 if ((flags \u0026amp; RTE_TCP_SYN_FLAG) != 0 \u0026amp;\u0026amp; 16 tcp_status == USTACK_TCP_STATUS_LISTEN) { 17 18 uint32_t ack_to_peer = peer_seq + 1; 19 send_syn_ack(server_isn, ack_to_peer); 20 tcp_status = USTACK_TCP_STATUS_SYN_RCVD; 21 } 第 15 行：使用按位与检查 SYN 标志是否存在，因为一个 TCP 包可以同时带多个 Flags。 第 16 行：只有监听状态才把这个 SYN 当作新连接请求。 第 18 行：SYN 占用一个序列号，因此响应 ACK 为客户端 SEQ+1。 第 19 行：构造并发送 SYN | ACK 报文。 第 20 行：发送后进入 SYN_RCVD，等待三次握手的最后一个 ACK。 学习版还应继续改进：如果 SYN+ACK 实际没有成功进入 TX Queue，不能直接认为它已经发送；收到重复 SYN 时也应考虑重发 SYN+ACK。\n24. 逐行构造 SYN+ACK Ethernet 和 IPv4 Header 的写法与 UDP 响应相同，只需把 IP 上层协议及总长度改为 TCP：\n01 uint16_t tcp_len = sizeof(struct rte_tcp_hdr); 02 uint16_t ip_len = sizeof(struct rte_ipv4_hdr) + tcp_len; 03 uint16_t frame_len = sizeof(struct rte_ether_hdr) + ip_len; 04 05 ip-\u0026gt;total_length = rte_cpu_to_be_16(ip_len); 06 ip-\u0026gt;next_proto_id = IPPROTO_TCP; 07 ip-\u0026gt;hdr_checksum = 0; 08 ip-\u0026gt;hdr_checksum = rte_ipv4_cksum(ip); 09 10 struct rte_tcp_hdr *tcp = 11 (struct rte_tcp_hdr *)(ip + 1); 12 *tcp = (struct rte_tcp_hdr){0}; 13 14 tcp-\u0026gt;src_port = global_sport; 15 tcp-\u0026gt;dst_port = global_dport; 16 tcp-\u0026gt;sent_seq = rte_cpu_to_be_32(server_isn); 17 tcp-\u0026gt;recv_ack = rte_cpu_to_be_32(peer_seq + 1); 18 tcp-\u0026gt;data_off = 19 (sizeof(struct rte_tcp_hdr) / sizeof(uint32_t)) \u0026lt;\u0026lt; 4; 20 tcp-\u0026gt;tcp_flags = RTE_TCP_SYN_FLAG | RTE_TCP_ACK_FLAG; 21 tcp-\u0026gt;rx_win = rte_cpu_to_be_16(TCP_INIT_WINDOW); 22 tcp-\u0026gt;cksum = 0; 23 tcp-\u0026gt;cksum = rte_ipv4_udptcp_cksum(ip, tcp); 第 1 行：SYN+ACK 不带 payload，本例 TCP 段只有基础 TCP Header。 第 2～3 行：依次得到 IPv4 总长度与完整帧长度，用于 Header 和 mbuf。 第 5 行：IPv4 总长度包含 IPv4 Header 与 TCP Header，不含 Ethernet Header。 第 6 行：把 IPv4 上层协议改为 TCP。 第 7～8 行：写完影响 IPv4 Header 的字段后重新计算 Header checksum。 第 10～11 行：TCP Header 紧跟在基础 IPv4 Header 后面。 第 12 行：清零整个 TCP Header，避免窗口、紧急指针等字段含有随机值。 第 14～15 行：写入对调后的端口。 第 16 行：服务端第一次发送 SYN，SEQ 等于本端 ISN。 第 17 行：确认客户端的 SYN，所以 ACK 等于客户端 SEQ+1。 第 18～19 行：TCP Header 长度以 32 位字为单位，并放在 data_off 的高 4 位。 第 20 行：同时置位 SYN 与 ACK。 第 21 行：窗口是 16 位字段，要转换为网络字节序。 第 22～23 行：清零后计算覆盖 IPv4 伪首部和 TCP 段的 checksum。 完成 Header 后，仍要像 UDP 一样分配/扩展 mbuf、调用 rte_eth_tx_burst() 并处理未发送的 mbuf。\n25. 验证三次握手最后一个 ACK 01 if ((flags \u0026amp; RTE_TCP_ACK_FLAG) != 0 \u0026amp;\u0026amp; 02 tcp_status == USTACK_TCP_STATUS_SYN_RCVD \u0026amp;\u0026amp; 03 peer_ack == server_isn + 1) { 04 05 tcp_status = USTACK_TCP_STATUS_ESTABLISHED; 06 printf(\u0026#34;enter established\\n\u0026#34;); 07 } 第 1 行：检查 ACK 标志存在。 第 2 行：只有已经发送 SYN+ACK 的连接才能通过这个分支建立。 第 3 行：客户端必须确认服务端的 SYN；因为 SYN 占一个序列号，所以期望值是 server_isn + 1。 第 5 行：状态迁移到 ESTABLISHED。 第 6 行：打印日志，方便结合抓包确认状态变化时机。 草稿最初只检查“有 ACK 标志且当前是 SYN_RCVD”，没有验证 ACK 数值。补上第 3 行后，三次握手判断才更完整。\n26. 计算 TCP payload，而不是只检查 PSH 01 uint16_t ip_hdr_len = 02 (iphdr-\u0026gt;version_ihl \u0026amp; 0x0f) * 4; 03 uint16_t tcp_hdr_len = 04 (tcphdr-\u0026gt;data_off \u0026gt;\u0026gt; 4) * 4; 05 uint16_t ip_total_len = 06 rte_be_to_cpu_16(iphdr-\u0026gt;total_length); 07 08 if (ip_total_len \u0026lt; ip_hdr_len + tcp_hdr_len) { 09 continue; 10 } 11 12 uint16_t payload_len = 13 ip_total_len - ip_hdr_len - tcp_hdr_len; 14 uint8_t *payload = 15 (uint8_t *)tcphdr + tcp_hdr_len; 16 17 if (tcp_status == USTACK_TCP_STATUS_ESTABLISHED \u0026amp;\u0026amp; 18 payload_len \u0026gt; 0) { 19 printf(\u0026#34;tcp data: %.*s\\n\u0026#34;, 20 (int)payload_len, 21 (char *)payload); 22 } 第 1～2 行：从 IHL 低 4 位得到 IPv4 Header 的 32 位字数量，再乘 4 得到字节数。 第 3～4 行：从 data_off 高 4 位得到 TCP Header 的字节数，因此 TCP Options 也会被正确跳过。 第 5～6 行：读取 IPv4 Header 声明的整个 IP 包长度。 第 8～10 行：总长度不能比两个 Header 相加还小，否则减法会发生无符号下溢。 第 12～13 行：用 IP 总长度减去两个 Header，得到 TCP payload 长度。 第 14～15 行：根据真实 TCP Header 长度定位 payload，而不是固定写 tcphdr + 1。 第 17 行：只有已建立连接才把数据交给应用层。 第 18 行：以实际 payload 长度判断是否有数据，不依赖 PSH 标志。 第 19～21 行：使用带长度的格式打印，避免把网络数据误当作以 \\0 结尾的字符串。 收到数据后还应该回复 ACK：\nack_to_peer = peer_seq + payload_len; 如果该段带 SYN 或 FIN，还应为每个标志额外加 1。当前草稿尚未实现数据 ACK，因此还只是握手与数据解析原型。\n27. RX mbuf 的最终释放位置 接收循环应为每个包建立唯一、清楚的释放路径。使用新 TX mbuf 构造响应时，可以在本轮处理结束后释放 RX mbuf：\nfor (uint16_t i = 0; i \u0026lt; num_recvd; i++) { bool rx_forwarded_to_tx = false; /* 解析并处理 mbufs[i] */ if (!rx_forwarded_to_tx) { rte_pktmbuf_free(mbufs[i]); } } 当前 UDP/TCP 示例新建了 tx_mbuf，所以原 mbufs[i] 没有转交给 TX，应由接收循环释放。草稿中缺少这一步，会导致 RX mbuf 持续泄漏，最终耗尽 mempool。\n如果以后改为原地改写并发送 RX mbuf，则发送成功后应把 rx_forwarded_to_tx 设为 true，防止重复释放。\n十四、怎样验证实现是否正确 建议按层验证，不要一上来同时调 UDP 和 TCP。\n阶段一：只验证 TX Queue 网口成功配置一个 TX Queue； rte_eth_tx_burst() 返回 1； 抓包能看到 DPDK 网卡发出的帧。 阶段二：验证 UDP Echo 请求和响应的 MAC、IP、Port 方向相反； Echo payload 与请求完全一致； IPv4 total_length、UDP dgram_len 正确； Wireshark 未报告 IPv4/UDP checksum 错误； 连续发送大量数据时，mempool 可用数量没有持续下降。 若开启了 TX checksum offload，抓包点可能位于网卡真正计算 checksum 之前，抓包工具会显示“校验和错误”；需要结合 offload 配置和抓包位置判断，不能只看这一条提示。\n阶段三：验证 TCP 握手 SYN 到达时，状态为 LISTEN； SYN+ACK 的 ACK 等于客户端 SEQ+1； 客户端最终 ACK 等于服务端 SEQ+1； 只有 ACK 校验通过后才进入 ESTABLISHED； 发送 payload 后，能正确计算 TCP Header 长度和 payload 长度。 常用观察点：\n源/目的 MAC 源/目的 IP 源/目的端口 EtherType / IP Protocol IP total_length UDP length 或 TCP data offset TCP flags / seq / ack checksum rte_eth_tx_burst 返回值 十五、把实验抽象成 pktgen 发包工具 完成 UDP/TCP 编码后，可以把“回包程序”进一步抽象为一个简单的发包工具。命令行可以设计为：\n./ustack-pktgen \\ --udp \\ --src-ip 192.168.1.10 \\ --dst-ip 192.168.1.20 \\ --src-port 10000 \\ --dst-port 20000 \\ --dst-mac 00:11:22:33:44:55 \\ --count 1000 \\ --payload \u0026#34;hello dpdk\u0026#34; TCP 模式可改用 --tcp，并增加：\n--flags syn|ack|psh|fin|rst --seq N --ack N --window N 程序结构可以拆成：\n参数解析 ├── 公共参数：源/目的 MAC、IP、数量、长度 ├── UDP 参数：源/目的端口、payload └── TCP 参数：flags、seq、ack、window ↓ 统一 packet description ↓ encode_ether + encode_ipv4 ↓ encode_udp 或 encode_tcp ↓ burst send + 统计 这个小工具的价值在于：\n为协议栈、网关、防火墙等项目构造可重复的测试流量； 精确控制五元组、TCP Flags、SEQ/ACK 等字段； 练习 DPDK 多包批量构造和发送； 后续可增加速率限制、不同包长、随机地址、时延与丢包统计。 在简历中应写清“实现了哪些能力”和“测得什么结果”，不要泛写成“实现 TCP/IP 协议栈”。例如：\n基于 DPDK 实现用户态 UDP Echo 与最小 TCP 服务端握手原型，完成 Ethernet/IPv4/UDP/TCP 报文构造、校验和计算、TCP 状态迁移及 mbuf 批量收发；进一步抽象可配置五元组与报文数量的流量生成工具，用于协议功能测试。\n若有实测结果，可以继续补充包长、队列数、CPU 核数、吞吐量和丢包率。\n十六、本篇回顾清单 读完代码后，应能不看资料回答这些问题：\n为什么配置了 RX Queue 仍不能发送数据？ 为什么把收到的 mbuf 原样送进 TX Queue 不构成 Echo？ UDP Echo 需要交换哪三层的哪些字段？ Ethernet FCS 与 IPv4/UDP/TCP checksum 分别由谁处理？ ip-\u0026gt;total_length、udp-\u0026gt;dgram_len、mbuf-\u0026gt;pkt_len 各自包含什么？ 为什么 payload 复制长度不能直接使用 UDP dgram_len？ rte_eth_tx_burst() 返回 0 时，mbuf 应由谁释放？ SYN 和 FIN 为什么会各占用一个序列号？ 为什么 TCP Header 没有类似 UDP 的长度字段？ 为什么仅凭 PSH 标志不能判断 TCP 包一定携带数据？ 为什么一个全局 tcp_status 无法支持多个 TCP 连接？ 当前原型距离完整 TCP 协议栈还缺哪些关键能力？ 十七、总结 本篇真正跨过的一步，是从“解析别人构造好的报文”走向“自己构造一个能被对端接受的报文”。\nUDP Echo 说明了发包的基本方法：\n分配 mbuf → 填各层 Header → 复制 payload → 写网络字节序 → 计算 checksum → 交给 TX Queue TCP 实验则进一步说明：协议栈不只是报文格式的集合，还必须维护状态和时序。能生成 SYN+ACK 只是起点；要成为完整 TCP 实现，还需要可靠传输、序列号校验、超时重传、窗口管理、连接表和连接关闭等机制。\n从工程实践看，本次代码最值得记住的不是某个固定 API，而是四条原则：\n每一层的长度边界必须算清楚； 所有多字节协议字段都要明确主机/网络字节序； checksum 计算前先清零，并明确由软件还是硬件负责； 任何 mbuf 都必须有清晰的所有权和释放路径。 ","date":"2026-08-17T00:00:00+08:00","image":"/MyBlog/p/dpdk-userspace-stack-udp-echo-tcp-handshake/cover.svg","permalink":"/MyBlog/p/dpdk-userspace-stack-udp-echo-tcp-handshake/","title":"DPDK 用户态协议栈（二）：从收包到 UDP Echo 与 TCP 握手"},{"content":" 学习目标：理解网卡、网卡驱动、TCP/IP 协议栈、POSIX Socket API 与应用程序之间的关系；搭建 DPDK 环境；使用 DPDK 收取以太网帧；逐步解析 Ethernet、IPv4、UDP、TCP；最终理解用户态协议栈需要补齐哪些能力。\n一、先建立完整的网络数据路径 可以把网络程序拆成五个逻辑模块：\n1. 网卡 NIC ↕ 2. 网卡驱动 Driver ↕ 3. TCP/IP 协议栈 ↕ 4. POSIX Socket API：socket / bind / listen / accept / recv / send ↕ 5. Application：Redis / Nginx / 自己编写的服务器 接收数据时，数据总体从左向右、从上向下流动；发送数据时方向相反。\n不过，必须区分下面两条不同的数据路径。\n1. Linux 内核协议栈路径 物理网络 → 网卡 → Linux 内核网卡驱动 → sk_buff → 内核 Ethernet/IP/TCP/UDP 协议栈 → Socket 接收缓冲区 → recv()/read() → 应用程序 应用程序使用的是 Linux 已经实现好的协议栈。Redis、Nginx 默认走的就是这条路径。\n2. DPDK 用户态路径 物理网络 → 网卡 → VFIO/UIO + DPDK PMD 用户态驱动 → RX Descriptor Ring → rte_mbuf → 自己实现或引入的用户态协议栈 → 自己实现的 Socket/POSIX 兼容层 → 应用程序 网卡绑定给 DPDK 使用的驱动后，该端口的普通数据收发路径通常绕过 Linux 内核网卡驱动和内核 TCP/IP 协议栈。DPDK 负责高速收发原始数据包，但 DPDK 本身不是 TCP/IP 协议栈。\n重要纠错：不能把 DPDK 主路径理解成“网卡 → DPDK → 内核驱动 → 内核 TCP/IP”。正常的 DPDK 用户态收包路径绕过内核协议栈。只有主动使用 KNI、TAP、virtio-user 等机制时，才会把选定的数据包重新送入内核。\n二、网卡是什么 1. 网卡的基本职责 网卡（NIC，Network Interface Card）连接计算机和网络介质，主要完成：\n接收和发送物理信号； 完成物理层编码、解码、时钟恢复等工作； 识别和生成以太网帧； 校验帧的 FCS； 按配置进行 MAC 地址过滤、VLAN 处理； 通过 DMA 在网卡和主机内存之间搬运数据； 一些网卡还能完成 checksum、TSO、LRO、RSS 等硬件卸载。 “网卡把模拟信号通过 AD/DA 转换为 0 和 1”可以帮助入门理解，但并不完全准确。现代以太网 PHY 会进行复杂的线路编码、调制、均衡和信号恢复，不能简单等同于普通 ADC/DAC。\n2. 网卡属于哪一层 网卡不是完全独立于分层模型之外。通常可以这样理解：\nPHY 部分主要工作在物理层； MAC 部分主要工作在数据链路层； checksum、TSO、RSS 等卸载能力会辅助更高层处理。 因此，说网卡“主要横跨物理层和数据链路层”更准确。\n3. DMA：网卡如何把数据交给内存 网卡不会要求 CPU 一个字节一个字节地读取数据。典型过程如下：\n驱动在内存中准备一组 RX 描述符和数据缓冲区； 驱动把这些内存地址告诉网卡； 网卡收到数据后，通过 DMA 直接把数据写入内存缓冲区； 网卡更新 RX 描述符，标记该数据包已经完成； CPU 再处理描述符所指向的数据。 这里的 RX 描述符通常按环形结构组织，所以常称为 RX Ring 或 Descriptor Ring。\n三、网卡驱动做什么 网卡驱动是操作系统或 DPDK 与具体网卡硬件之间的适配层。它通常负责：\n根据 PCI Vendor ID / Device ID 识别硬件； 初始化网卡寄存器； 配置 MAC、MTU、收发队列和硬件卸载； 分配 RX/TX Descriptor Ring； 建立 DMA 映射； 启停网卡； 处理链路状态和错误； 在中断模式或轮询模式下回收、提交数据包。 Linux 内核中有统一的网络设备框架，e1000、ixgbe、i40e、mlx5、vmxnet3 等具体驱动在这个框架下实现。DPDK 则提供 PMD（Poll Mode Driver），让用户态程序通过轮询直接与网卡队列交互。\n1. 中断模式与轮询模式 传统内核路径通常结合中断和 NAPI 轮询：\n流量较低时，网卡通过中断通知 CPU； 流量较高时，NAPI 暂时关闭频繁中断，改用批量轮询； 这样可以避免“中断风暴”。 DPDK 的典型方式是 Poll Mode：\nrte_eth_rx_burst(port_id, queue_id, mbufs, BURST_SIZE); 线程不断轮询指定的端口和队列，避免中断、调度和上下文切换开销。代价是即使没有数据，轮询线程也可能持续占用一个 CPU 核。\n2. 多队列网卡 多队列网卡拥有多个 RX/TX 队列。其价值在于：\n不同队列可以由不同 CPU 核或线程独立处理； 减少多个核心竞争同一个队列锁； 利用 RSS 把不同网络流分散到多个 RX 队列； 提升并行处理能力和总体吞吐量。 常见绑定关系是：\nRX Queue 0 → lcore 1 RX Queue 1 → lcore 2 RX Queue 2 → lcore 3 RX Queue 3 → lcore 4 RSS 通常根据源/目的 IP、源/目的端口、协议号计算哈希，将同一个流稳定地送到同一队列，避免同一 TCP 流的数据包在多个核心间乱序处理。\n重要纠错：多队列能显著提高并行性，但“不是多队列网卡就一定无法绑定 DPDK”并不准确。单队列设备也可能被支持，只是扩展能力差。能否绑定和运行取决于设备、PMD、IOMMU、VFIO/UIO 以及虚拟化环境等条件。\n3. 虚拟机中的 vmxnet3 VMware 中可以在 .vmx 文件中指定：\nethernet0.virtualDev = \u0026#34;vmxnet3\u0026#34; ethernet0.wakeOnPcktRcv = \u0026#34;TRUE\u0026#34; vmxnet3 是 VMware 的半虚拟化高性能网卡，通常比模拟的 e1000 拥有更好的性能和多队列能力。修改配置前应关闭虚拟机，并确认实际使用的 ethernet0、ethernet1 与目标 MAC 地址相匹配。\n四、sk_buff、rte_mbuf 与 Ring 1. Linux 的 sk_buff Linux 内核用 struct sk_buff 描述网络包。它不是简单地把所有协议头复制一遍，而是保存：\n数据缓冲区的位置； 当前数据起点和末尾； MAC、网络层和传输层头的位置； 数据包长度； 所属设备； checksum、VLAN、路由等元数据； 链表等组织信息。 协议层之间传递的通常是 sk_buff 指针。各层通过移动或记录头部位置来解析数据，而不是每经过一层就复制整个包。\n2. 海量 sk_buff 如何组织 “每包一个 sk_buff，海量包如何组织”不能只用一种数据结构回答。不同阶段使用不同结构：\n网卡收发描述符：环形队列 Ring； NAPI 待处理对象：调度列表； Socket 接收队列：链表/队列形式的 skb queue； TCP 乱序数据：红黑树等结构； TCP 重传数据：按发送顺序管理的队列/树； 内存对象分配：slab/slub 缓存。 所以，常用答案是“以 Ring 和各种队列为主，根据查找、排序、重传等需求再使用树结构”。\n3. DPDK 的 rte_mbuf rte_mbuf 与 sk_buff 的定位相似：都用来描述一个数据包及其元数据。DPDK 通常从预先创建的 mempool 中分配 mbuf，以减少频繁动态分配的开销。\nmempool ├── rte_mbuf + data buffer ├── rte_mbuf + data buffer ├── rte_mbuf + data buffer └── ... 网卡通过 DMA 把数据写入 mbuf 对应的数据缓冲区。rte_eth_rx_burst() 返回的是已经装有数据的 mbuf 指针数组，因此 CPU 不需要再把整个数据包从内核复制到用户态。\n这通常称为零拷贝或少拷贝设计，但严格来说仍然可能发生 DMA、缓存行传输、跨 NUMA 访问以及应用层复制，不能把“零拷贝”理解成整个系统完全没有任何数据移动。\n五、协议栈是什么 协议栈是一组按层协作的协议实现。接收一个 UDP/IPv4 以太网帧时，可依次解析：\nEthernet Header → IPv4 Header → UDP Header → Application Payload 接收 TCP 时则是：\nEthernet Header → IPv4 Header → TCP Header → TCP Segment Payload 解析协议头只是用户态协议栈的第一步。一个真正可用的协议栈还需要实现 ARP、路由、校验和、分片重组、TCP 状态机、重传、拥塞控制、Socket 缓冲区等功能。\n六、以太网帧解析 不考虑 VLAN 标签时，Ethernet II 头部固定为 14 字节：\n0 5 6 11 12 13 +----------------+----------------+--------+ | 目的 MAC 6字节 | 源 MAC 6字节 | 类型 2 | +----------------+----------------+--------+ 常见 EtherType：\n0x0800：IPv4； 0x0806：ARP； 0x86DD：IPv6； 0x8100：802.1Q VLAN。 DPDK 中对应：\nstruct rte_ether_hdr *ethhdr = rte_pktmbuf_mtod(mbufs[i], struct rte_ether_hdr *); rte_pktmbuf_mtod() 的作用是把 mbuf 中的数据起始地址转换成指定类型的指针。它并没有复制以太网头，只是在同一段内存上按 struct rte_ether_hdr 的布局解释字节。\n判断是否为 IPv4：\nif (ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)) { continue; } 网络协议字段通常采用大端字节序，所以要进行主机字节序与网络字节序转换。\nVLAN 注意事项 代码直接把以太网头后的数据当作 IPv4，只适用于没有 VLAN 标签的帧。若 EtherType 为 0x8100，还要解析 4 字节 VLAN Header，然后才能找到真正的上层 EtherType。\n七、IPv4 解析 IPv4 基础头部最少 20 字节，结构包括：\n+---------+---------+-------------------+ |Version | IHL | DSCP/ECN | Length | +---------+---------+-------------------+ | Identification | Flags/Fragment Offset| +---------------------------------------+ | TTL | Protocol | Header Checksum | +---------------------------------------+ | Source IPv4 Address | +---------------------------------------+ | Destination IPv4 Address | +---------------------------------------+ | Options（可选，IHL \u0026gt; 5 时存在） | +---------------------------------------+ 关键字段：\nVersion：IPv4 时为 4； IHL：头部长度，以 4 字节为单位；最小值为 5，即 20 字节； Total Length：整个 IPv4 数据报长度； Fragment Offset / Flags：分片相关； TTL：每经过一个路由器通常减 1； Protocol：上层协议，UDP=17，TCP=6，ICMP=1； Header Checksum：只校验 IPv4 头； Source/Destination Address：源和目的 IPv4 地址。 原代码通过固定的 14 字节 Ethernet Header 偏移找到 IPv4 头：\nstruct rte_ipv4_hdr *iphdr = rte_pktmbuf_mtod_offset( mbufs[i], struct rte_ipv4_hdr *, sizeof(struct rte_ether_hdr)); 判断 UDP：\nif (iphdr-\u0026gt;next_proto_id == IPPROTO_UDP) { IPv4 解析必须检查的内容 健壮的实现至少应检查：\nmbuf 中是否有完整的 Ethernet 和 IPv4 基础头； IPv4 Version 是否为 4； IHL 是否至少为 5； 包长度是否覆盖 IHL * 4； Total Length 是否合理； IPv4 Header Checksum 是否正确，或确认硬件已校验； 是否为分片； 再根据 Protocol 分发给 ICMP、UDP 或 TCP。 原代码使用 (iphdr + 1) 寻找 UDP 头，相当于默认 IPv4 头固定为 20 字节。若 IPv4 带 Options，IHL 会大于 5，UDP 头的真实起点应为 IP Header 起点 + IHL * 4。\n八、UDP 解析 UDP 头固定为 8 字节：\n+-------------------+-------------------+ | Source Port | Destination Port | +-------------------+-------------------+ | UDP Length | UDP Checksum | +-------------------+-------------------+ | Payload ... | +---------------------------------------+ 关键字段：\nSource Port：源端口； Destination Port：目的端口； Length：UDP 头加 Payload 的总长度，最小为 8； Checksum：覆盖伪首部、UDP 头和 UDP 数据。 原代码：\nstruct rte_udp_hdr *udphdr = (struct rte_udp_hdr *)(iphdr + 1); printf(\u0026#34;udp:%s\\n \u0026#34;, (char *)(udphdr + 1)); 第一行把 IPv4 基础头后的地址解释为 UDP Header；第二行把 UDP Header 后的数据解释为 C 字符串并打印。\n为什么 %s 只适合当前演示 网络 Payload 不保证以 \\0 结尾，而 %s 会持续读取，直到遇到 \\0。因此实际代码必须：\n根据 UDP Length 计算 Payload 长度； 确认长度没有超过 mbuf/IPv4 数据长度； 使用带长度的输出方式，例如 %.*s； 对二进制协议应使用十六进制打印，而不是字符串打印。 当前 Packet Sender 发送的是短 ASCII 文本，并且内存后面恰好出现零字节，所以演示中可能正常输出，但这不是安全的通用处理方法。\n九、TCP 解析 TCP 头最少为 20 字节，可带 Options，最长可达 60 字节。\n+-------------------+-------------------+ | Source Port | Destination Port | +---------------------------------------+ | Sequence Number | +---------------------------------------+ | Acknowledgment Number | +------+----------+---------------------+ |Offset| Flags | Window | +-----------------+---------------------+ | Checksum | Urgent Pointer | +---------------------------------------+ | Options（可选） | +---------------------------------------+ | Payload ... | +---------------------------------------+ 关键字段：\nSequence Number：本段第一个字节的序号； Acknowledgment Number：期望收到的下一个序号； Data Offset：TCP Header 长度，以 4 字节为单位； Flags：SYN、ACK、FIN、RST、PSH、URG 等； Window：接收窗口，参与流量控制； Checksum：覆盖 IPv4/IPv6 伪首部、TCP 头和 TCP 数据。 只把 TCP Header 拆出来还不能叫“实现 TCP”。至少还需要：\n四元组查找连接：源 IP、源端口、目的 IP、目的端口； TCP 状态机； 三次握手和四次挥手； 序列号和 ACK； 乱序重组； 超时重传； RTT/RTO 估计； 滑动窗口和流量控制； 拥塞控制； TIME_WAIT 等定时器管理。 TCP 状态机简图 服务器：CLOSED → LISTEN → SYN_RECEIVED → ESTABLISHED 客户端：CLOSED → SYN_SENT → ESTABLISHED 主动关闭的一方：ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED 被动关闭的一方：ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED 十、POSIX Socket API 与用户态协议栈 POSIX API 是应用程序看到的接口：\nsocket()：创建 Socket； bind()：绑定本地 IP 和端口； listen()：把 TCP Socket 设为监听状态； accept()：取出一个已经建立的 TCP 连接； connect()：主动发起 TCP 连接； recv()/read()：读取接收数据； send()/write()：发送数据； close()：关闭 Socket。 1. 内核中的关系 在 Linux 内核协议栈中，文件描述符 fd 会关联到内核 Socket 对象。TCP 连接还对应一个传输控制块，可广义称为 TCB，其中保存：\n四元组； TCP 状态； 序列号； 收发窗口； 重传队列； 接收队列； 定时器等。 recv(fd, ...) 不是简单地“直接读取 TCB 某个位置”，而是通过 fd 找到 Socket，再从该 Socket 的接收缓冲区读取已经按序整理好的字节。如果没有数据，调用可能阻塞，或者在非阻塞模式下返回 EAGAIN。\n2. 用户态协议栈怎样提供 POSIX API 如果应用完全绕过内核协议栈，Linux 原生 recv() 不会自动读取 DPDK mbuf。需要增加适配层，常见方法包括：\n修改应用，让它调用用户态协议栈自己的 API； 使用库替换或 LD_PRELOAD 截获 Socket 调用； 提供兼容 POSIX 的用户态 Socket 库； 通过事件通知机制实现类似 epoll 的接口。 用户态需要自己维护：\nfd/table → socket object → UDP endpoint 或 TCP TCB → receive queue / send queue 对于 UDP，recvfrom() 通常从对应端口的报文队列取出一个 Datagram；对于 TCP，recv() 从已经重排并确认连续的字节流中读取数据。\n十一、DPDK 是什么，以及为什么快 DPDK（Data Plane Development Kit）是一组用于高速数据包处理的库、驱动和工具。它的核心思想包括：\n用户态 PMD 轮询网卡； 批量收发 Burst； 预分配 mempool/mbuf； Hugepage； CPU 亲和性和独占核心； NUMA 感知； 无锁 Ring； 硬件多队列与 RSS； Prefetch 和缓存友好布局； 减少中断、系统调用、上下文切换和数据复制。 DPDK 优化的核心指标 DPDK 不只是“处理大包、提高吞吐量”。更准确地说，它常用于提高：\n每秒处理的数据包数 PPS，尤其是大量小包； 总吞吐量； 延迟的可预测性和尾延迟； 多核心扩展能力。 是否能提升 Redis/Nginx 性能，取决于瓶颈：\n如果瓶颈在业务逻辑、锁、存储或单线程执行，只有 DPDK 不会神奇地提高 QPS； 如果瓶颈在内核网络路径、软中断、系统调用或数据复制，用户态网络方案可能改善 QPS 或延迟； 把现有 Redis/Nginx 接到 DPDK 上需要完整协议栈和兼容层，并非简单绑定网卡即可。 常见使用场景 防火墙、ACL、DPI； 路由器、交换机、网关； 负载均衡器； 5G/电信数据面； 虚拟交换机； 流媒体或报文转发； 流量采集与监控； 高性能存储网络的数据面。 数据备份和 RDMA 可能使用类似的大页、DMA、零拷贝、用户态驱动思想，但 DPDK 与 RDMA 是不同的技术体系，不能简单说“底层一定用 DPDK，应用层用 RDMA”。\n可研究的用户态协议栈/数据面项目包括 mTCP、lwIP、F-Stack、VPP、Seastar networking、基于 BSD 协议栈移植的实现等。项目是否仍活跃、DPDK 版本是否兼容，需要按实际版本确认。\n十二、VFIO、UIO、PCI、KNI 与 NUMA 1. PCI 地址 网卡通常是 PCIe 设备。可以使用：\nlspci lspci -nn lspci -k PCI 地址形如：\n0000:1a:00.0 含义是：\ndomain:bus:device.function 2. VFIO 与 UIO 它们帮助用户态程序访问 PCI 设备资源：\nUIO：较简单的通用用户态 I/O 框架； VFIO：通常配合 IOMMU，隔离性和安全性更好，现代环境一般优先使用； DPDK PMD：真正理解并操作具体网卡寄存器和队列的用户态驱动。 不能简单理解为 VFIO/UIO“截获 PCI 地址”；更准确地说，它们把设备资源安全地映射或暴露给用户态驱动，并管理中断、DMA/IOMMU 等能力。\n3. KNI KNI 曾用于在 DPDK 应用与 Linux 内核网络栈之间交换数据包。例如，把控制面流量送回内核处理。但它不是 DPDK 收包必须经过的组件，而且在较新的 DPDK 方案中通常会考虑 TAP、virtio-user 等替代路径。\n4. NUMA NUMA 是 Non-Uniform Memory Access，即非一致内存访问。多路 CPU 服务器中，每个 CPU Socket 通常拥有本地内存：\nCPU Socket 0 ↔ Local Memory 0 CPU Socket 1 ↔ Local Memory 1 CPU 访问本地内存比跨 Socket 访问远端内存更快。因此应尽量做到：\n网卡所在 NUMA Node； 轮询该网卡的 CPU 核； mbuf mempool 所在内存； 三者位于同一个 NUMA Node。\n代码中的 rte_eth_dev_socket_id() 用于查询设备所在 NUMA Socket，rte_socket_id() 返回当前 lcore 所在 Socket。二者不一定总是相同。\n十三、Hugepage 大页 普通页通常为 4 KiB。Hugepage 常见规格为：\n2 MiB； 1 GiB。 DPDK 使用大页主要是为了：\n减少页表项数量； 降低 TLB Miss； 便于大块、稳定、可用于 DMA 的内存管理； 减少运行时内存分配的不确定性。 1. 示例 GRUB 配置 GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;default_hugepagesz=2M hugepagesz=2M hugepages=1024\u0026#34; 这表示默认大页为 2 MiB，共预留 1024 页，即约 2 GiB 内存。虚拟机内存有限时，需要根据虚拟机总内存调整，不能机械照抄。\n通常修改后还需要更新 GRUB 并重启，例如 Ubuntu/Debian：\nsudo update-grub sudo reboot 重启后验证：\ngrep -i huge /proc/meminfo 具体命令因发行版和 DPDK 版本而异。现代 DPDK 可能自动处理 hugetlbfs，也可以手动挂载。1 GiB 大页还依赖 CPU 支持，并通常需要在启动参数中预留。\n2. Hugepage 不直接提升网卡物理转换速度 Hugepage 优化的是主机内存管理、TLB 和 DMA 缓冲区访问，并不会让网卡的光电信号转换本身更快。它提升的是数据包在 CPU/内存数据面中的处理效率和稳定性。\n十四、VMware 三种网络模式 1. Bridged（桥接） 虚拟机像局域网中的独立主机：\n物理局域网 ├── Windows/macOS 宿主机：172.26.185.5 └── 虚拟机：172.26.185.250 虚拟机通常与宿主机处于同一二层网络，可从局域网 DHCP 获取地址，或配置同网段静态地址。桥接绑定到某个宿主机物理接口时，该接口断开可能影响虚拟机网络。\n“复制物理网络连接状态”之类的选项，通常让虚拟网卡随宿主机物理接口的连接/断开状态变化，适合笔记本在 Wi-Fi、有线网络之间切换的场景。不同 VMware 产品中的名称和细节会略有不同。\n2. NAT VMware 在宿主机上创建虚拟子网，虚拟机通过宿主机做 NAT 访问外网：\n虚拟机 → VMware 虚拟网关/NAT → 宿主机 → 外网 外部设备通常不能直接主动访问虚拟机，除非做端口映射或额外配置。\n3. Host-only（仅主机） 虚拟机主要与宿主机及同一 Host-only 网络中的虚拟机通信，默认不能直接访问外网。它适合隔离实验环境。\n“Host-only 一定可以 ping 通公网”是不对的；默认情况下它不能上网，除非宿主机另行开启路由/NAT。\n十五、macOS Packet Sender 测试链路 已知 DPDK 端口信息：\nMAC：00:0c:29:aa:5b:f0 PCI：0000:1a:00.0 PMD：net_vmxnet3 Link：up 宿主机：\nIP：172.26.185.5 Mask：255.255.0.0 Interface：en0 ustack 测试地址：\n172.26.185.250 1. 为什么需要静态 ARP 应用把 UDP 发往 172.26.185.250:8888 时，操作系统需要构造以太网帧。因为目标 IP 在同一子网，macOS 必须知道目标 IP 对应的 MAC 地址。\n正常情况是：\nmacOS 广播 ARP Request：谁是 172.26.185.250？ ustack 单播 ARP Reply：172.26.185.250 是 00:0c:29:aa:5b:f0 当前 ustack 没有实现 ARP Reply，所以手工建立：\nsudo arp -s 172.26.185.250 00:0c:29:aa:5b:f0 sudo arp -s 172.26.185.250 00:0c:29:aa:5b:f0 ifscope en0 验证：\narp -n 172.26.185.250 2. Packet Sender 配置 Address：172.26.185.250 Port：8888 Protocol：UDP ASCII：hello dpdk 完整路径：\nPacket Sender → macOS UDP/IP 处理 → 静态 ARP 表查到目标 MAC → macOS en0 → VMware Fusion 桥接 → vmxnet3 虚拟网卡 → DPDK port 0 / RX queue 0 → rte_eth_rx_burst() → Ethernet / IPv4 / UDP 解析 → 打印 hello dpdk 看到：\nudp:hello dpdk 说明至少以下环节已经连通：\nmacOS 到 Fusion 桥接网络； 目标 MAC 投递； vmxnet3 链路； DPDK 端口初始化； RX Queue 0 收包； 当前测试报文的 Ethernet/IPv4/UDP 基础解析。 但它还不能单独证明 checksum、分片、VLAN、IP Options、异常长度处理等已经正确实现。\n十六、DPDK 环境搭建思路 老版本 DPDK 常使用 dpdk-setup.sh、RTE_SDK 和 RTE_TARGET。典型含义是：\nexport RTE_SDK=/home/.../dpdk export RTE_TARGET=... RTE_SDK：DPDK 源码/SDK 根目录； RTE_TARGET：旧版构建系统生成的目标目录名称。 native 一般表示按当前机器架构优化；linuxapp 是旧版目标名称的一部分；具体选项需要以所使用的旧版 DPDK 文档和脚本为准。\n现代 DPDK 已主要使用 Meson + Ninja 构建，不再依赖旧式 make config、RTE_TARGET 流程：\nmeson setup build ninja -C build 因此必须先确认教程使用的 DPDK 版本。不要把老教程里的菜单选项直接套到新版本。\n通用搭建检查表 确认 CPU 架构、Linux 发行版和 DPDK 版本； 确认 vmxnet3/物理网卡被当前 DPDK PMD 支持； 配置 Hugepage； 若使用 VFIO，确认 IOMMU/VFIO 条件； 查看 PCI 地址和当前驱动； 将实验网卡绑定到合适驱动； 保留管理网卡，避免把 SSH 所依赖的唯一网卡绑定走； 用 testpmd 检查端口、队列、Link 和收包计数； 再运行自己的程序。 安全提示：绑定网卡前一定确认 PCI 地址。将正在承载 SSH 或默认路由的网卡绑定给 DPDK，会立即让普通 Linux 网络连接中断。\n十七、原始代码（保持不变） 下面代码完全按原内容保留，没有修改：\n#include\u0026lt;stdio.h\u0026gt; #include\u0026lt;rte_eal.h\u0026gt; #include\u0026lt;rte_ethdev.h\u0026gt; #include\u0026lt;arpa/inet.h\u0026gt; int global_portid=0;//网卡绑定id从0开始 发数据和收数据知道id就好 #define NUM_MBUFS 4096 #define BURST_SIZE 128 static const struct rte_eth_conf port_conf_default={ .rxmode={.max_rx_pkt_len=RTE_ETHER_MAX_LEN} }; static int ustack_init_port(struct rte_mempool *mbuf_pool){ uint16_t nb_sys_ports=rte_eth_dev_count_avail(); if(nb_sys_ports==0){ rte_exit(EXIT_FAILURE,\u0026#34;No Supported eth found/n\u0026#34;); } //struct rte_eth_dev_info dev_info; //rte_eth_dev_info_get(global_portid，\u0026amp;dev_info); //绑定的网卡 //现在有八个 队列 弄1 2 4 8 const int num_rx_queues =1; const int num_tx_queues =0; rte_eth_dev_configure(global_portid,num_rx_queues,num_tx_queues,\u0026amp;port_conf_default); if(rte_eth_rx_queue_setup(global_portid,0,128,rte_eth_dev_socket_id(global_portid),NULL,mbuf_pool)\u0026lt;0){ //在0号网卡的0号队列开辟了RX队列 准备了128个mbuffer rte_exit(EXIT_FAILURE,\u0026#34; Could not setup RX queue/n\u0026#34;); } if (rte_eth_dev_start(global_portid)\u0026lt;0){//启动 rte_exit(EXIT_FAILURE,\u0026#34;Could not start\u0026#34;); } return 0; } int main(int argc,char *argv[]){ if(rte_eal_init(argc,argv)\u0026lt;0){//初始化 检查网卡信息 配置 是否绑定 rte_exit(EXIT_FAILURE,\u0026#34;Error with EAL init\\n\u0026#34;); } struct rte_mempool *mbuf_pool= rte_pktmbuf_pool_create(\u0026#34;mbuf pool\u0026#34;,NUM_MBUFS,0,0,RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id()); //接收数据放在mbuff //mbuffer分配在内存上的 rte_socket_id 就是当前使用哪块内存卡槽ID分配的 if(mbuf_pool==NULL){ rte_exit(EXIT_FAILURE,\u0026#34;Could not create mbuf pool\\n\u0026#34;); } ustack_init_port(mbuf_pool); //接收数据 while(1){ struct rte_mbuf *mbufs[BURST_SIZE]={0}; uint16_t num_recvd= rte_eth_rx_burst(global_portid,0,mbufs,BURST_SIZE); if (num_recvd\u0026gt;BURST_SIZE){ rte_exit(EXIT_FAILURE,\u0026#34;Error receiving from eth\\n\u0026#34;); } int i=0; for(i=0;i\u0026lt;num_recvd;i++){ struct rte_ether_hdr *ethhdr=rte_pktmbuf_mtod(mbufs[i],struct rte_ether_hdr *); if(ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){ continue; } struct rte_ipv4_hdr *iphdr =rte_pktmbuf_mtod_offset(mbufs[i],struct rte_ipv4_hdr *,sizeof(struct rte_ether_hdr)); if (iphdr-\u0026gt;next_proto_id==IPPROTO_UDP){//两个以上都转 struct rte_udp_hdr *udphdr =(struct rte_udp_hdr *)(iphdr+1); printf(\u0026#34;udp:%s\\n \u0026#34;,(char*)(udphdr+1)); } } } printf(\u0026#34;Hello dpdk\\n\u0026#34;); } 十八、代码逐行对照理解 1. 头文件 #include\u0026lt;stdio.h\u0026gt; 引入标准输入输出声明，本程序用它调用 printf()。\n#include\u0026lt;rte_eal.h\u0026gt; 引入 DPDK EAL 接口。EAL 是 Environment Abstraction Layer，负责 Hugepage、lcore、内存、PCI 设备等基础环境初始化，也声明了 rte_eal_init()、rte_exit() 等接口。\n#include\u0026lt;rte_ethdev.h\u0026gt; 引入以太网设备抽象接口，包括端口配置、RX Queue 设置、设备启动和 Burst 收包。\n#include\u0026lt;arpa/inet.h\u0026gt; 提供网络字节序、IP 地址转换等常用声明。当前代码中没有直接调用 htons()、ntohs() 等函数，但 IPPROTO_UDP 等定义可能通过相关系统头可见。实际项目通常还会显式包含 DPDK Ethernet/IP/UDP 头文件，具体取决于 DPDK 版本的包含关系。\n2. 全局端口与常量 int global_portid=0; 选择 DPDK 逻辑端口 0。这里的 port id 是 DPDK 枚举后的端口编号，不等于 PCI 地址，也不一定等于 Linux 的 eth0。\n#define NUM_MBUFS 4096 准备在 mempool 中创建 4096 个 mbuf。它们不仅供 128 个 RX 描述符使用，还要留给收包后尚未释放、缓存以及其他内部需求。\n#define BURST_SIZE 128 每次调用 rte_eth_rx_burst()，最多希望取回 128 个包。实际返回值可以是 0 到 128。\n3. 默认端口配置 static const struct rte_eth_conf port_conf_default={ .rxmode={.max_rx_pkt_len=RTE_ETHER_MAX_LEN} }; 创建端口配置结构，把最大接收包长设为标准以太网最大长度对应的 DPDK 常量。\n注意：DPDK 的结构体字段会随版本变化。某些新版本中 max_rx_pkt_len 的位置或用法已变化。如果发生编译错误，应首先对照当前版本 API，而不是认为网络原理有问题。\n4. 端口初始化函数 static int ustack_init_port(struct rte_mempool *mbuf_pool){ 定义只在本源文件使用的函数，参数是已经创建好的 mbuf 内存池。\nuint16_t nb_sys_ports=rte_eth_dev_count_avail(); 查询当前 EAL 环境中可用的 DPDK Ethernet 设备数量。nb 是 number 的常用缩写。它不是固定表示“绑定网口数量”，更准确地说是当前可用的 ethdev 数量。\nif(nb_sys_ports==0){ rte_exit(EXIT_FAILURE,\u0026#34;No Supported eth found/n\u0026#34;); } 如果没有设备，打印错误并退出。字符串中的 /n 是普通字符，不是换行符；真正的换行符写法是 \\n。本笔记遵守要求，不修改原代码，只指出区别。\n//struct rte_eth_dev_info dev_info; //rte_eth_dev_info_get(global_portid，\u0026amp;dev_info); 这两行被注释，不会执行。设计意图是读取端口能力，比如最大队列数、RSS、offload 能力。第二行参数之间使用了中文逗号 ，，若取消注释需要注意它不能作为 C 语言逗号使用。\nconst int num_rx_queues =1; const int num_tx_queues =0; 配置 1 个接收队列、0 个发送队列。此程序只收不发，所以 TX Queue 设为 0。若以后要回复 ARP、UDP 或 TCP，就必须配置 TX Queue。\nrte_eth_dev_configure(global_portid,num_rx_queues,num_tx_queues,\u0026amp;port_conf_default); 配置端口的 RX/TX 队列数量和总体参数。函数有返回值，当前代码没有检查；健壮程序应检查是否小于 0。\nif(rte_eth_rx_queue_setup(global_portid,0,128, rte_eth_dev_socket_id(global_portid),NULL,mbuf_pool)\u0026lt;0){ 配置 RX Queue：\nglobal_portid：端口 0； 0：RX Queue 0； 128：描述符数量； rte_eth_dev_socket_id(...)：在网卡所在 NUMA Socket 分配队列资源； NULL：使用默认 RX Queue 配置； mbuf_pool：网卡收到的数据放入这个池提供的 mbuf。 注释“准备了 128 个 mbuffer”是便于理解的近似说法。准确说是请求建立 128 个 RX descriptors；描述符与 mbuf 的补充、占用和回收由 PMD 管理，mempool 总大小仍为 4096。\nrte_exit(EXIT_FAILURE,\u0026#34; Could not setup RX queue/n\u0026#34;); 配置失败就退出。同样，/n 不会产生换行。\nif (rte_eth_dev_start(global_portid)\u0026lt;0){ rte_exit(EXIT_FAILURE,\u0026#34;Could not start\u0026#34;); } 启动端口。配置端口和队列只是准备工作，start 后网卡才进入正式收发状态。\nreturn 0; 表示初始化成功。\n5. main 与 EAL 初始化 int main(int argc,char *argv[]){ 程序入口。DPDK 会先解析命令行中的 EAL 参数，应用自己的参数通常放在 -- 后面并根据 rte_eal_init() 的返回值调整。\nif(rte_eal_init(argc,argv)\u0026lt;0){ rte_exit(EXIT_FAILURE,\u0026#34;Error with EAL init\\n\u0026#34;); } 初始化 DPDK 运行环境，涉及：\n解析 EAL 参数； 发现 CPU lcore； 初始化 Hugepage 内存； 初始化内存管理； 探测并初始化设备和总线； 建立主从 lcore 等运行环境。 它不等于“自动保证所有网卡已经正确绑定”。绑定通常要在运行程序之前完成，EAL 负责发现当前可使用的设备。\n6. 创建 mbuf pool struct rte_mempool *mbuf_pool= rte_pktmbuf_pool_create(\u0026#34;mbuf pool\u0026#34;,NUM_MBUFS,0,0, RTE_MBUF_DEFAULT_BUF_SIZE,rte_socket_id()); 参数逐个理解：\n\u0026quot;mbuf pool\u0026quot;：内存池名称； NUM_MBUFS：4096 个 mbuf； 第一个 0：每个 lcore 的 cache 数量为 0； 第二个 0：每个对象额外私有区域大小为 0； RTE_MBUF_DEFAULT_BUF_SIZE：每个 mbuf 默认数据缓冲区大小； rte_socket_id()：在当前运行核心所在 NUMA Socket 分配。 这里 rte_socket_id() 指 NUMA 节点编号，不是物理“内存卡槽 ID”。服务器硬件的 DIMM 插槽和 NUMA Node 不是同一个概念。\nif(mbuf_pool==NULL){ rte_exit(EXIT_FAILURE,\u0026#34;Could not create mbuf pool\\n\u0026#34;); } 创建失败立即退出。可能原因包括 Hugepage 不足、名称冲突、socket 内存不够或参数不合法。\nustack_init_port(mbuf_pool); 用刚创建的内存池配置并启动端口。\n7. 轮询收包 while(1){ 进入无限循环。这是 PMD 轮询模型的核心，线程会持续询问 RX Queue 是否有包。\nstruct rte_mbuf *mbufs[BURST_SIZE]={0}; 创建一个长度为 128 的指针数组。数组保存 rte_mbuf *，并不在栈上创建 128 个完整 mbuf。\nuint16_t num_recvd= rte_eth_rx_burst(global_portid,0,mbufs,BURST_SIZE); 从端口 0、队列 0 最多取 128 个已经收到的包：\n返回 0：当前没有包； 返回 1～128：实际取到的包数； mbuf 指针写入 mbufs[0 ... num_recvd-1]。 rte_eth_rx_burst() 通常通过检查 RX descriptors 批量完成工作，不是每次都触发系统调用。\nif (num_recvd\u0026gt;BURST_SIZE){ rte_exit(EXIT_FAILURE,\u0026#34;Error receiving from eth\\n\u0026#34;); } 按 API 契约，返回值不会大于传入的 BURST_SIZE，所以这个判断正常情况下不会成立。它也不能检测一般收包错误，因为该 API 的返回值语义主要是“本次收到多少包”。\n8. 遍历每一个包 int i=0; for(i=0;i\u0026lt;num_recvd;i++){ 依次处理本轮收到的所有 mbuf。\nstruct rte_ether_hdr *ethhdr= rte_pktmbuf_mtod(mbufs[i],struct rte_ether_hdr *); 取得包数据起点，把它解释成 Ethernet Header。\nif(ethhdr-\u0026gt;ether_type != rte_cpu_to_be_16(RTE_ETHER_TYPE_IPV4)){ continue; } 如果 EtherType 不是 IPv4，就跳过后续解析。ARP、IPv6、VLAN 等都会走 continue。\n这里有一个重要的生命周期问题：跳过不等于释放。当前程序没有调用 rte_pktmbuf_free(mbufs[i])，因此收到的 mbuf 不会归还 mempool。运行一段时间后，RX Queue 可能因为拿不到空闲 mbuf 而停止继续收包。\nstruct rte_ipv4_hdr *iphdr =rte_pktmbuf_mtod_offset( mbufs[i],struct rte_ipv4_hdr *,sizeof(struct rte_ether_hdr)); 从数据起点向后移动一个 Ethernet Header 的长度，即 14 字节，再把地址解释成 IPv4 Header。\n当前代码在解引用前没有检查包的实际长度；遇到截断帧或异常帧时可能越界读取。\nif (iphdr-\u0026gt;next_proto_id==IPPROTO_UDP){ 检查 IPv4 Protocol 字段是否等于 UDP 的协议号 17。\nstruct rte_udp_hdr *udphdr =(struct rte_udp_hdr *)(iphdr+1); iphdr + 1 会跳过一个 struct rte_ipv4_hdr，也就是按 20 字节基础 IPv4 头定位 UDP Header。它没有考虑 IHL 大于 5的 IPv4 Options。\nprintf(\u0026#34;udp:%s\\n \u0026#34;,(char*)(udphdr+1)); udphdr + 1 跳过 8 字节 UDP Header，将后续 Payload 当作字符串打印。它没有依据 UDP Length 限制读取长度，也没有保证 Payload 以 \\0 结尾，因此只适合受控的入门测试。\n9. 循环之后的代码 printf(\u0026#34;Hello dpdk\\n\u0026#34;); 由于前面是无限循环，并且没有 break，正常情况下这一行不可达。\n十九、今天内容的核心总结 网卡把网络介质上的信号变成帧，并通过 DMA 把数据放入主机内存；它主要涉及物理层和数据链路层。 驱动负责初始化网卡、配置队列和 DMA，并让软件能够操作具体硬件。 Linux 使用 sk_buff 描述数据包；DPDK 使用 rte_mbuf，两者都不只是数据本身，还包含元数据。 多队列让多个 CPU 核并行处理流量，RSS 负责把流分配到不同队列；多队列很重要，但不是所有 DPDK 绑定的绝对前提。 DPDK 用用户态轮询、Burst、Hugepage、mempool、多队列和 NUMA 优化高速包处理。 网卡绑定给 DPDK 后，常规数据路径绕过内核 TCP/IP；因此要么引入现有用户态协议栈，要么自己实现。 Ethernet 普通头为 14 字节，IPv4 最少 20 字节，UDP 固定 8 字节，TCP 最少 20 字节。 “能找到协议头并打印 Payload”只是解析器原型，不等于完整协议栈。 完整 UDP 还需要 ARP、IP、校验和、端口分发、队列和 API；完整 TCP 还需要状态机、序列号、重传、窗口、定时器等。 POSIX recv() 读取的是 Socket 接收缓冲区；用户态协议栈必须自己建立 fd、Socket、TCB 和收发队列之间的关系。 当前测试通过静态 ARP 绕过了尚未实现的 ARP Reply，成功证明了 macOS、Fusion 桥接、vmxnet3、DPDK RX 和基础 UDP 解析链路已打通。 下一步最重要的不是立即写 TCP，而是先补齐 mbuf 释放、长度校验、ARP Reply、TX 和 UDP Echo。 二十、版本说明与官方参考 你的学习材料明显包含旧版 DPDK 构建流程，因此本笔记保留了 dpdk-setup.sh、RTE_SDK、RTE_TARGET 的历史解释，同时把现代版本单独说明。实际执行命令时，必须先运行 dpdk-testpmd -v、查看源码目录中的 release 信息或构建文件，确认本机 DPDK 版本。\nDPDK Linux Getting Started Guide：现代 Linux 环境、编译、驱动和运行入口； DPDK Poll Mode Driver：PMD 绕过传统内核网络栈、直接轮询 RX/TX Descriptor 的官方说明； DPDK Linux Drivers：VFIO、UIO、设备绑定和安全性说明； DPDK VMXNET3 PMD：vmxnet3 收发环、多队列/RSS、功能与限制； DPDK Hugepage Tool：查看、预留和挂载大页； DPDK 22.11 KNI 文档：KNI 的用途、弃用状态及 virtio-user 替代说明。 ","date":"2026-08-15T00:00:00+08:00","image":"/MyBlog/p/dpdk-userspace-network-stack/cover.svg","permalink":"/MyBlog/p/dpdk-userspace-network-stack/","title":"DPDK 用户态协议栈设计与实现"},{"content":" 本篇承接 yield、resume、ucontext 和简单轮询调度，重点理解阻塞式 I/O 的 Hook、协程状态管理、调度器与 epoll 的协作，以及 x86-64 汇编上下文切换。\n一、从上下文切换走向完整协程运行时 已经掌握的两个方向：\nmain → ctx ：resume ctx → main ：yield 它们底层都依赖一次 context switch：\n保存当前执行现场 ↓ 恢复目标执行现场 ↓ 目标协程从上次暂停位置继续 但只有 switch、resume 和 yield 还不够。真正处理网络 I/O 还需要回答：\n1. 普通read/recv没有数据时，怎样自动yield？ 2. fd就绪以后，怎样找到并resume对应协程？ 3. 十万个协程如何保存和组织？ 4. READY、WAITING、SLEEPING、EXITED如何管理？ 5. 定时器和I/O超时如何处理？ 6. 上下文切换怎样用汇编实现？ 7. 多核机器上怎样运行多个调度器？ 最终需要形成：\nPOSIX I/O调用 ↓ Hook层 ↓ 非阻塞I/O + epoll ↓ yield / resume ↓ 协程状态和调度器 ↓ ucontext或汇编context switch 二、为什么需要 Hook I/O 一个协程中的业务代码希望保持顺序结构：\nwhile(1){ recv(); parser(); send(); } 阅读时仍然是：\n接收数据 ↓ 解析数据 ↓ 发送响应 但如果这里直接调用阻塞式 recv()，没有数据时会阻塞整个线程，线程内其他协程也无法运行。\n协程库希望在不大改业务流程的情况下，把调用替换为自己的实现：\n业务调用recv/read ↓ 进入协程库Hook函数 ↓ 先尝试非阻塞I/O ├── 已就绪：调用真实系统函数并返回 └── 未就绪：注册epoll事件并yield ↓ fd就绪 ↓ resume协程 ↓ 再调用真实系统函数 这就是：\n上层保持同步代码结构，底层通过 epoll 和协程实现异步等待。\n三、Hook 的概念流程 笔记中的伪代码：\nrecv（fd,buffer,length）{ struct pollfd fds[1]={0}; fds[0].fd=fd; if(0\u0026lt;poll(fd,1,0)){//io可读 recv_f(fd,buffer,length); }else{ epoll_ctl(epfd,EPOLL_CTL_ADD,fd,\u0026amp;ev); swapcontext(); } } 它表达了四步。\n3.1 检查 fd 是否就绪 poll(fd,1,0) timeout 为 0，表示立即检查，不在 poll() 中等待。\n返回值 \u0026gt; 0 → 当前fd有事件 返回值 = 0 → 当前没有事件 返回值 \u0026lt; 0 → 调用发生错误 3.2 已就绪时调用真实函数 recv_f(fd,buffer,length); recv_f 指向真正的系统 recv，而不是 Hook 后的同名函数。\n3.3 未就绪时注册 epoll epoll_ctl(epfd,EPOLL_CTL_ADD,fd,\u0026amp;ev); 同时记录：\n哪个fd 等待什么事件 哪个协程正在等待它 是否存在超时时间 3.4 当前协程 yield swapcontext(); 这里代表概念上的 yield。实际实现需要明确传入当前协程上下文和调度器上下文。\n当前协程WAITING ↓ yield回调度器 ↓ 调度器运行其他READY协程 四、使用 dlsym 拦截 read 和 write 以下是提供的 Hook 代码：\n#define _GNU_SOURCE #include \u0026lt;dlfcn.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;ucontext.h\u0026gt; #include \u0026lt;string.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; #include \u0026lt;fcntl.h\u0026gt; #include \u0026lt;sys/socket.h\u0026gt; #include \u0026lt;errno.h\u0026gt; #include \u0026lt;netinet/in.h\u0026gt; #include \u0026lt;pthread.h\u0026gt; #include \u0026lt;sys/poll.h\u0026gt; #include \u0026lt;sys/epoll.h\u0026gt; 4.1 为什么需要 _GNU_SOURCE #define _GNU_SOURCE 它在包含系统头文件前启用 GNU 扩展声明，使 dlsym()、RTLD_NEXT 等接口在相应环境中可见。\n4.2 函数指针类型 原代码：\ntypedef ssize_t (*read_t)(int fd, void *buf, size_t count); read_t read_f = NULL; typedef ssize_t (*write_t)(int fd, const void *buf, size_t count); write_t write_f = NULL; read_t 描述真实 read() 的函数类型：\n参数1：fd 参数2：接收缓冲区 参数3：最大读取长度 返回值：实际读取长度或错误 write_t 对应真实 write()。\nread_f → 保存原始read地址 write_f → 保存原始write地址 五、init_hook() 如何取得真实函数 原代码：\nvoid init_hook(void) { if (!read_f) { read_f = dlsym(RTLD_NEXT, \u0026#34;read\u0026#34;); } if (!write_f) { write_f = dlsym(RTLD_NEXT, \u0026#34;write\u0026#34;); } } dlsym() 根据符号名查找函数地址：\ndlsym(RTLD_NEXT, \u0026#34;read\u0026#34;); RTLD_NEXT 的含义可以理解为：\n从当前 Hook 定义之后继续寻找下一个名为 read 的实现。\n因此调用关系是：\n业务代码调用read() ↓ 进入当前文件定义的Hook read() ↓ Hook内部调用read_f() ↓ 进入真正的系统read() 如果 Hook 内部再次直接调用 read()：\nHook read() ↓ 又进入Hook read() ↓ 无限递归 所以必须使用 read_f 调用真实函数。\n使用 dlsym() 时通常需要链接动态加载库：\ngcc ... -ldl 六、read() Hook 的执行过程 原代码：\nssize_t read(int fd, void *buf, size_t count) { struct pollfd fds[1] = {0}; fds[0].fd = fd; fds[0].events = POLLIN; int res = poll(fds, 1, 0); if (res \u0026lt;= 0) { //不可读 // fd --\u0026gt; epoll_ctl(); swapcontext(); // fd --\u0026gt; ctx fd与context一对一 } // io ssize_t ret = read_f(fd, buf, count); printf(\u0026#34;read: %s\\n\u0026#34;, (char *)buf); return ret; } 6.1 构造 pollfd struct pollfd fds[1] = {0}; fds[0].fd = fd; fds[0].events = POLLIN; 表示只检查一个 fd 的可读事件。\n6.2 立即检查 int res = poll(fds, 1, 0); 第三个参数为 0：\n不等待 立即返回当前状态 6.3 不可读时暂停协程 if (res \u0026lt;= 0) { // fd --\u0026gt; epoll_ctl(); swapcontext(); } 完整运行时还需要在 yield 前完成：\n将fd设置为非阻塞 将fd注册到epoll 记录fd等待POLLIN/EPOLLIN 记录当前等待协程 设置可选超时时间 当前协程状态改为WAITING yield到scheduler fd 就绪后：\nepoll_wait返回fd ↓ 找到等待它的协程 ↓ 协程进入READY队列 ↓ scheduler resume协程 ↓ 从swapcontext()之后继续 ↓ read_f()真正读取数据 6.4 调用真实 read ssize_t ret = read_f(fd, buf, count); 这一步才真正从内核读取数据。\npoll 告诉程序“当前可能可读”，read_f 才完成实际读取。就绪状态可能在检查和读取之间变化，因此 fd 仍然应该是非阻塞的，并正确处理 EAGAIN。\n七、write() Hook 原代码：\nssize_t write(int fd, const void *buf, size_t count) { printf(\u0026#34;write: %s\\n\u0026#34;, (const char *)buf); return write_f(fd, buf, count); } 当前实现主要展示：\n业务调用write ↓ 进入Hook write ↓ 调用write_f进入真实write 完整协程版 write() 也需要处理：\n真实write成功一部分 → 记录已写偏移，继续写剩余数据 真实write返回EAGAIN → 注册EPOLLOUT → 当前协程yield → fd可写后resume Hook write() 内部使用 printf() 记录日志时要特别小心，因为 printf() 最终也可能调用 write()，从而再次进入 Hook。实际实现通常使用不会再次触发该 Hook 的日志路径，或者直接调用保存下来的真实 write_f。\n八、Hook 示例怎样接入普通服务端 原代码：\nint main() { init_hook(); int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serveraddr; memset(\u0026amp;serveraddr, 0, sizeof(struct sockaddr_in)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons(2048); if (-1 == bind(sockfd, (struct sockaddr*)\u0026amp;serveraddr, sizeof(struct sockaddr))) { perror(\u0026#34;bind\u0026#34;); return -1; } listen(sockfd, 10); struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); int clientfd = accept(sockfd, (struct sockaddr*)\u0026amp;clientaddr, \u0026amp;len); printf(\u0026#34;accept\\n\u0026#34;); while (1) { char buffer[128] = {0}; int count = read(clientfd, buffer, 128); if (count == 0) { break; } write(clientfd, buffer, count); printf(\u0026#34;sockfd: %d, clientfd: %d, count: %d, buffer: %s\\n\u0026#34;, sockfd, clientfd, count, buffer); } return 0; } 业务代码仍然调用：\nread(clientfd, buffer, 128); write(clientfd, buffer, count); 但由于程序中定义了同名 read() 和 write()，符号解析会先进入 Hook。\n这正是 Hook 的价值：\n业务代码结构保持不变 ↓ read/write调用被协程库接管 ↓ 等待I/O时自动yield ↓ 就绪后自动resume 当前示例只接收一个 clientfd，重点是展示 Hook 链路。与真正协程服务器结合时，还需要 accept Hook、多连接协程、epoll mainloop 和 scheduler。\n九、为什么第三方库也可以受益 例如本地程序使用 hiredis 连接 Redis。hiredis 内部会调用 read/write 或 send/recv 完成网络操作。\n不修改 hiredis 源码时：\n业务调用hiredis ↓ hiredis内部调用read/write ↓ 进入协程库Hook ↓ 没有数据时yield ↓ Redis响应到达后resume 因此原本按照阻塞式 POSIX API 编写的库，有机会直接运行在协程调度环境中。\n前提是 Hook 覆盖了它实际调用的 API，并正确处理：\n阻塞标志 超时 EINTR EAGAIN 部分读写 close和fd复用 线程安全 十、如何定义一个协程 笔记中的初始设计：\nstruct coroutinue{ int fd; ucontext_t ctx;//stack ，stack_size,func包含在里面 void *arg }; 一个协程需要保存两类信息。\n10.1 执行现场 ctx → 寄存器、栈指针、执行位置等上下文 stack → 协程自己的栈 stack_size → 栈大小 entry → 协程入口函数 arg → 入口参数 10.2 调度状态 id → 协程标识 state → READY、RUNNING、WAITING等 fd → 当前等待的I/O对象 events → 等待EPOLLIN或EPOLLOUT expire_at → 超时时间或唤醒时间 scheduler → 所属调度器 概念上的完整结构：\nstruct coroutine ├── id ├── state ├── context ├── stack / stack_size ├── entry / arg ├── waiting_fd / events ├── expire_at ├── ready_node ├── wait_node ├── sleep_node └── scheduler 本篇沿用原笔记中的 coroutinue 拼写来对应代码，实际项目中可以统一为 coroutine。\n十一、协程入口与创建 原始设计：\nvoid func(void){ } typedef void *(*coroutinue_entry)(void *) int create_coroutinue(co_id id,coroutinue_entry entry,void *arg){ struct coroutinue *co =malloc(sizeof(struct coroutinue)); co.ss_sp= makecontext(co-\u0026gt;ctx,func,0); } 创建协程需要完成：\n1. 分配struct coroutine 2. 分配或绑定独立栈 3. 保存entry和arg 4. 初始化context 5. 设置入口包装函数 6. 设置协程退出后的返回目标 7. state设为READY 8. 加入ready_queue 为什么通常需要一个入口包装函数：\nscheduler resume新协程 ↓ 进入wrapper ↓ wrapper调用entry(arg) ↓ entry返回 ↓ wrapper把协程状态设为EXITED ↓ yield回scheduler ↓ scheduler回收资源 如果直接让入口函数随意 return，而没有统一退出路径，调度器很难正确修改状态和释放栈。\n十二、十万个协程如何组织 假设采用“一连接一协程”：\n10万个fd ↓ 10万个coroutine对象 但它们不会全部同时运行。\n例如：\n10万个协程 ├── 2万个READY ├── 5万个WAITING ├── 2万个SLEEPING └── 1万个已结束或待回收 调度器必须按状态组织它们，不能每轮扫描全部十万个对象。\n十三、READY 为什么使用队列 笔记中的设计：\nqueue_node (corountine,)ready_queue READY 协程表示现在就可以运行。\n常见操作：\n协程变为READY → 从队尾加入 scheduler取任务 → 从队头取出 协程yield但仍可运行 → 再放回队尾 因此队列适合：\nO(1)入队 O(1)出队 天然支持轮询公平调度 如果有 2 万个 READY 协程：\nready_head → co1 → co2 → co3 → ... → co20000 scheduler 每次取队头协程 resume。\n十四、WAITING 与 SLEEPING 为什么需要有序结构 笔记中的设计：\nrbtree_node(coroutinue,)wait_rb; rbtree_node(coroutinue,)sleep_rb; 等待 I/O 的协程可能还设置超时：\n协程A等待fd=4，100ms后超时 协程B等待fd=5，500ms后超时 协程C等待fd=6，50ms后超时 睡眠协程也有不同唤醒时间：\n协程D睡眠到10:00:00.100 协程E睡眠到10:00:00.800 如果以 expire_at 为 key 放入有序结构，调度器可以快速找到最早到期节点：\n最小expire_at ↓ 与当前时间比较 ↓ 已经到期就移出 ↓ 状态改为READY ↓ 加入ready_queue 红黑树、最小堆、跳表、时间轮都可以实现定时管理，具体选择取决于插入、删除、精度和规模要求。\nI/O fd 到协程的查找，还可以另外使用：\nepoll_event.data.ptr 哈希表 fd → coroutine 数组 fd → coroutine 时间树解决“谁先超时”，fd 映射解决“哪个事件唤醒谁”，它们是两个不同查找维度。\n十五、一个协程为什么可以同时拥有多个节点 笔记中的思路是把数据结构节点直接放进 coroutine：\nready_node wait_node sleep_node 这种方式称为侵入式数据结构：\ncoroutine对象本身包含链表节点或树节点 ↓ 不需要额外分配包装节点 ↓ 从节点可以找回所属coroutine 但协程状态必须保持一致：\nREADY → 位于ready_queue WAITING → 位于I/O等待集合，可能同时位于超时树 SLEEPING → 位于sleep定时结构 RUNNING → 当前正在执行，通常不在ready_queue EXITED → 位于回收集合或等待立即回收 协程从一个状态切到另一个状态时，需要从旧集合移除，再加入新集合，避免同一个节点被重复挂载。\n十六、scheduler 如何定义 笔记中的设计：\nscheduler struct scheduler{ int epfd struct epoll_event events[]; queue_node (corountine,)ready_head rbtree_root(coroutinue,)wait_rb; rbtree_root(coroutinue,)sleep_rb; }; 各字段含义：\nepfd → epoll实例 events[] → epoll_wait返回的就绪事件 ready_head → 当前可以运行的协程 wait_rb → 等待I/O并带超时的协程 sleep_rb → 主动睡眠的协程 完整调度器还会需要：\nmain_context → scheduler自己的上下文 current → 当前正在运行的协程 all_coroutines → 所有协程的所有权集合 exited_queue → 等待回收的协程 stop → 退出标志 thread_id → 所属线程 十七、哪些操作由协程执行，哪些由调度器执行 17.1 协程主动执行 调用read/recv/send/write 发现需要等待后yield 主动sleep 业务处理 入口函数返回并exit 17.2 调度器执行 维护ready_queue 计算最近超时时间 调用epoll_wait 处理fd就绪事件 处理wait/sleep超时 把协程转为READY 选择下一个协程resume 回收EXITED协程 核心边界：\n协程表达“我要等待什么”，调度器决定“等待期间运行谁，以及何时唤醒我”。\n十八、调度循环的完整流程 一个合理的 scheduler mainloop 可以概括为：\nwhile没有停止： 1. 处理已经到期的sleep节点 2. 处理已经超时的I/O等待节点 3. 处理ready_queue中的协程 4. 根据最近定时器计算epoll_wait超时 5. 调用epoll_wait 6. 根据events找到等待协程 7. 将就绪协程加入ready_queue 8. 回收已经退出的协程 如果 ready_queue 非空：\n可以先继续运行READY协程 epoll_wait使用timeout=0避免阻塞 如果 ready_queue 为空：\n根据最近timer计算等待时间 ↓ epoll_wait可以阻塞到I/O到达或定时器到期 这样线程不会忙等。\n十九、协程状态怎样流转 典型状态：\nNEW ↓ create READY ↓ scheduler resume RUNNING ├── 等待I/O → WAITING ├── sleep → SLEEPING ├── 主动yield且仍可运行 → READY └── entry返回 → EXITED WAITING ├── fd就绪 → READY └── 超时 → READY，并携带timeout结果 SLEEPING └── 时间到 → READY EXITED ↓ scheduler回收 DESTROYED 状态与容器必须同步：\nstate = READY ↔ 在ready_queue中 state = WAITING ↔ 在I/O等待映射中 state = SLEEPING ↔ 在timer结构中 二十、setjmp、ucontext 和汇编怎样选择 三种方案：\nsetjmp / longjmp ucontext architecture-specific assembly 20.1 setjmp / longjmp 优点：\n标准C接口 可移植性相对好 适合理解保存点和恢复点 限制：\n不直接提供独立协程栈管理 构造完整栈式协程较复杂 20.2 ucontext 优点：\ngetcontext/makecontext/swapcontext语义清晰 可以直接指定独立栈和入口函数 教学和原型实现简单 限制：\n已经不属于现代POSIX标准 不同平台支持情况不一致 20.3 汇编 优点：\n直接控制保存和恢复的寄存器 切换路径短 适合性能敏感的运行时 限制：\n与CPU架构、ABI和调用约定绑定 x86-64、ARM64等需要不同实现 测试和维护成本高 性能不能只靠固定排名判断，需要在相同编译器、架构和切换模型下基准测试。汇编通常能做得更精简，但正确性和可移植性同样重要。\n二十一、x86-64 汇编上下文切换原代码 #elif defined(__x86_64__) __asm__ ( \u0026#34; .text \\n\u0026#34; \u0026#34; .p2align 4,,15 \\n\u0026#34; \u0026#34;.globl _switch \\n\u0026#34; \u0026#34;.globl __switch \\n\u0026#34; \u0026#34;_switch: \\n\u0026#34; \u0026#34;__switch: \\n\u0026#34; \u0026#34; movq %rsp, 0(%rsi) # save stack_pointer \\n\u0026#34; \u0026#34; movq %rbp, 8(%rsi) # save frame_pointer \\n\u0026#34; \u0026#34; movq (%rsp), %rax # save insn_pointer \\n\u0026#34; \u0026#34; movq %rax, 16(%rsi) \\n\u0026#34; \u0026#34; movq %rbx, 24(%rsi) # save rbx,r12-r15 \\n\u0026#34; \u0026#34; movq %r12, 32(%rsi) \\n\u0026#34; \u0026#34; movq %r13, 40(%rsi) \\n\u0026#34; \u0026#34; movq %r14, 48(%rsi) \\n\u0026#34; \u0026#34; movq %r15, 56(%rsi) \\n\u0026#34; \u0026#34; movq 56(%rdi), %r15 \\n\u0026#34; \u0026#34; movq 48(%rdi), %r14 \\n\u0026#34; \u0026#34; movq 40(%rdi), %r13 # restore rbx,r12-r15 \\n\u0026#34; \u0026#34; movq 32(%rdi), %r12 \\n\u0026#34; \u0026#34; movq 24(%rdi), %rbx \\n\u0026#34; \u0026#34; movq 8(%rdi), %rbp # restore frame_pointer \\n\u0026#34; \u0026#34; movq 0(%rdi), %rsp # restore stack_pointer \\n\u0026#34; \u0026#34; movq 16(%rdi), %rax # restore insn_pointer \\n\u0026#34; \u0026#34; movq %rax, (%rsp) \\n\u0026#34; \u0026#34; ret \\n\u0026#34; ); #endif 对应的上下文结构：\ntypedef struct _nty_cpu_ctx { void *esp; // void *ebp; void *eip; void *edi; void *esi; void *ebx; void *r1; void *r2; void *r3; void *r4; void *r5; } nty_cpu_ctx; int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx); 函数语义：\nnew_ctx → 即将恢复的目标上下文 cur_ctx → 当前需要保存的上下文 在 System V AMD64 ABI 中，前两个整数或指针参数通常放在：\n第一个参数new_ctx → RDI 第二个参数cur_ctx → RSI 因此：\nRSI作为基地址 → 保存当前上下文 RDI作为基地址 → 加载目标上下文 二十二、汇编第一部分：保存当前上下文 22.1 保存栈指针 movq %rsp, 0(%rsi) 含义：\n把当前RSP保存到cur_ctx偏移0的位置 RSP 指向当前线程正在使用的栈顶。恢复 RSP 才能回到该协程自己的栈。\n22.2 保存帧指针 movq %rbp, 8(%rsi) 一个指针占 8 字节，所以第二个字段位于偏移 8。\n22.3 保存返回地址 movq (%rsp), %rax movq %rax, 16(%rsi) 调用 _switch 时，返回地址位于当前栈顶。先读到 RAX，再保存到 cur_ctx 偏移 16。\n这个地址就是以后恢复协程时需要继续执行的位置。\n22.4 保存被调用者保存寄存器 movq %rbx, 24(%rsi) movq %r12, 32(%rsi) movq %r13, 40(%rsi) movq %r14, 48(%rsi) movq %r15, 56(%rsi) 这些寄存器按照 x86-64 System V ABI 属于 callee-saved registers。函数返回后调用方有权期待它们保持不变，因此 context switch 必须保存和恢复。\n偏移每次增加 8：\n0 → rsp 8 → rbp 16 → rip/返回地址 24 → rbx 32 → r12 40 → r13 48 → r14 56 → r15 原结构中的 esp、ebp、eip 是 32 位风格名称；在这段 x86-64 汇编中实际保存的是 rsp、rbp 和返回地址。\n二十三、汇编第二部分：恢复目标上下文 movq 56(%rdi), %r15 movq 48(%rdi), %r14 movq 40(%rdi), %r13 movq 32(%rdi), %r12 movq 24(%rdi), %rbx movq 8(%rdi), %rbp movq 0(%rdi), %rsp movq 16(%rdi), %rax movq %rax, (%rsp) ret 这部分按相反方向加载 new_ctx：\n恢复r15、r14、r13、r12、rbx ↓ 恢复rbp ↓ 恢复目标协程rsp ↓ 把目标返回地址放到目标栈顶 ↓ ret弹出返回地址 ↓ 从目标协程保存位置继续执行 最关键的一步：\nmovq 0(%rdi), %rsp RSP 一旦切换，当前使用的栈就从旧协程栈变成了目标协程栈。\n最后：\nret 从目标栈弹出保存的指令地址，于是代码看起来像目标协程之前调用的 _switch() 正常返回。\n二十四、为什么不是保存所有寄存器 x86-64 有更多通用寄存器，但按照函数调用约定分为：\ncaller-saved → 调用方负责在需要时保存 callee-saved → 被调用函数必须保证返回后不变 _switch() 以普通函数调用形式进入，编译器会按照 ABI 处理 caller-saved 寄存器中的临时值，因此最小切换实现主要保存 callee-saved 寄存器、栈指针和返回位置。\n这依赖：\n明确的ABI 正确的函数声明 编译器遵循调用约定 汇编与结构体布局完全一致 System V AMD64 中，前六个整数或指针参数通常依次使用：\nRDI、RSI、RDX、RCX、R8、R9 函数并不是不能超过六个参数。超过六个的参数会按照 ABI 放到栈上，只是调用方式不再全部使用参数寄存器。\n二十五、从 Hook read 到调度器唤醒的完整链路 协程A调用read(fd) ↓ 进入Hook read ↓ poll或真实read发现暂时不可读 ↓ fd注册EPOLLIN ↓ 记录fd → 协程A ↓ 协程A状态RUNNING → WAITING ↓ _switch(scheduler_ctx, coA_ctx) ↓ scheduler运行其他READY协程 ↓ epoll_wait返回fd ↓ 根据event找到协程A ↓ 从等待集合删除协程A ↓ 协程A状态WAITING → READY ↓ 加入ready_queue ↓ scheduler选择协程A ↓ _switch(coA_ctx, scheduler_ctx) ↓ 协程A从Hook read中的yield位置继续 ↓ 调用read_f真正读取 ↓ 返回业务代码 这一整条链路把四个模块连接起来：\nHook层 epoll事件层 scheduler调度层 context switch底层 二十六、多线程与多进程模式 26.1 多线程模式 一种设计是多个线程各自运行调度器：\n线程1 → scheduler1 → epoll1 → 一组协程 线程2 → scheduler2 → epoll2 → 一组协程 线程3 → scheduler3 → epoll3 → 一组协程 这通常比多个线程共同操作一个 scheduler 更容易控制，因为每个协程和上下文固定属于一个线程。\n如果多个线程共享：\nready_queue wait tree sleep tree coroutine对象 就需要考虑锁、并发状态迁移、跨线程唤醒和缓存竞争。\nCPU affinity 可以让调度线程尽量固定在某个 CPU 核，减少迁移和缓存失效，但是否绑定需要通过测试决定。\n26.2 多进程模式 进程1 → scheduler1 → 一组连接 进程2 → scheduler2 → 一组连接 进程3 → scheduler3 → 一组连接 每个进程拥有独立地址空间和调度器，协程数据天然隔离，减少共享锁。\n需要额外考虑：\n监听端口如何共享 连接如何负载均衡 进程间通信 共享状态 进程崩溃恢复 多线程和多进程都能利用多核。区别主要在共享内存、隔离性和通信成本。\n二十七、怎样把协程能力用到实际项目 27.1 网络框架 以 ntyco 为基础理解：\ncoroutine scheduler epoll Hook timer assembly switch 重点不是只复现 switch，而是打通：\nI/O等待 → yield → epoll唤醒 → ready → resume 27.2 WebServer 一个连接或请求由一个协程处理 ↓ 协程recv HTTP请求 ↓ 解析协议 ↓ 查询Redis/MySQL ↓ 发送HTTP响应 每个等待点都由 Hook 和 scheduler 自动让出。\n27.3 KV 存储 例如 dkvstore：\n连接协程 ↓ 接收命令 ↓ 解析SET/GET ↓ 访问存储结构 ↓ 返回结果 27.4 图床 上传连接 ↓ 接收文件数据 ↓ 写磁盘或对象存储 ↓ 写数据库 ↓ 返回图片URL 这些项目能够体现的不是“使用了协程”一句话，而是：\n怎样Hook阻塞API 怎样管理十万连接状态 怎样设计ready/wait/sleep结构 怎样处理超时和部分读写 怎样在多核上部署调度器 怎样测试切换和网络性能 二十八、核心总结 重点一：Hook 让同步代码接入异步调度 业务仍然调用read/write ↓ Hook判断I/O状态 ↓ 不可用时注册epoll并yield ↓ 就绪后resume 重点二：协程对象既保存执行现场，也保存调度状态 context/stack → 回来后从哪里继续 state/fd/time → 什么时候可以回来 data nodes → 当前属于哪个调度集合 重点三：不同状态需要不同数据结构 READY → queue I/O映射 → epoll data、数组或哈希 超时/睡眠 → 红黑树、最小堆或时间轮 EXITED → 回收队列 重点四：scheduler 是整个运行时的中心 处理epoll事件 处理定时器 维护协程状态 选择READY协程 执行resume 接收yield 回收退出协程 重点五：汇编 switch 只做保存和恢复 RSI → 保存当前上下文 RDI → 恢复目标上下文 RSP → 切换协程栈 ret → 跳回目标协程保存位置 最终可以把协程运行时理解成：\nHook 决定何时等待，epoll 决定何时就绪，scheduler 决定运行谁，context switch 决定怎样切过去。\n","date":"2026-08-11T00:00:00+08:00","image":"/MyBlog/p/coroutine-runtime-io-hook-scheduler-context-switch/cover.svg","permalink":"/MyBlog/p/coroutine-runtime-io-hook-scheduler-context-switch/","title":"协程运行时：IO Hook、调度器与汇编上下文切换"},{"content":" 本篇承接已有的《网络IO》《IO 多路复用与 Reactor》《Reactor 与 epoll：百万并发网络服务器课后笔记》。\nsocket、fd、服务端基础代码、select/poll/epoll 和 Reactor 不再重复；\n一、为什么网络代码可以跨 Linux、Unix 和 macOS Linux 受到 Unix 设计影响，不同厂商和社区又形成了不同发行版。它们的内核、工具和扩展功能可能不同，但应用程序仍然可以使用一组相似的接口：\nsocket(); bind(); listen(); accept(); connect(); send(); recv(); close(); 重要原因是这些系统广泛支持 POSIX 和 BSD Socket 风格的接口规范。\n标准接口主要约定：\n函数叫什么 函数接收什么参数 返回值如何表达成功和失败 错误通过什么方式报告 应用程序可以依赖哪些行为 不同系统的内核实现可以不同，但只要对应用程序提供兼容接口，使用标准接口编写的代码就比较容易迁移：\n应用程序 ↓ POSIX / Socket API ↓ 不同操作系统的内核实现 代码可以直接迁移还要求它没有依赖某个系统的专有能力。例如 socket、bind、listen 是通用 Socket API；epoll 是 Linux 专用机制，迁移到 macOS 时通常要换成 kqueue。\n二、客户端和服务端使用的 API 2.1 TCP 客户端 课堂中的客户端调用顺序：\nsocket(); bind();//optional connect(); send(); recv(); close(); 对应作用：\nsocket() → 创建 socket 和 fd bind() → 可选，指定本地 IP 和本地端口 connect() → 指定对端并发起 TCP 连接 send() → 把应用数据交给内核 recv() → 从内核取出收到的数据 close() → 关闭 fd，并推动 TCP 关闭过程 客户端通常不主动调用 bind()。如果直接 connect()，内核会选择合适的本地 IP 和临时端口。\n临时端口范围由系统配置决定，不固定为 1024～65535。Linux 上可以查看：\ncat /proc/sys/net/ipv4/ip_local_port_range 客户端需要固定源 IP 或源端口时，才会在 connect() 前显式调用 bind()。\n2.2 TCP 服务端 课堂中的服务端调用顺序：\nsocket(); bind(); listen(); accept(); recv(); send(); close(); 与 Reactor 结合后还会使用：\nepoll_create(); epoll_ctl(); epoll_wait(); fcntl(); 这两组 API 位于不同层次：\nsocket/bind/listen/accept/connect/send/recv/close → 管理 TCP socket 和数据传输 epoll_create/epoll_ctl/epoll_wait → 管理大量 fd 的就绪事件 fcntl() 常用于把 fd 设置为非阻塞。\n三、一次 TCP 连接的三个阶段 1. 建立连接：三次握手 2. 数据传输：序号、确认、流量控制、拥塞控制和重传 3. 断开连接：FIN/ACK 与 TCP 状态迁移 应用程序看到的是：\nconnect() / accept() send() / recv() close() 内核真正执行的是 TCP 协议状态机。\n四、socket() 在应用层与内核之间建立了什么 课堂代码：\nfd=socket(); socket 的英文含义是插座。可以用“插头和插座”帮助理解应用层 fd 与内核 socket 对象的关系：\n应用层 fd：应用程序使用的整数句柄 ↓ 内核 socket/TCP相关对象：保存协议、地址、状态、缓冲区等 socket() 成功后主要完成两类事情。\n4.1 分配 fd fd 是当前进程文件描述符表中的一个位置。内核找到可用位置，将它标记为正在使用，然后返回对应整数。\n进程fd表 0 → 标准输入 1 → 标准输出 2 → 标准错误 3 → 新创建的socket 调用 close(fd) 后，该 fd 位置会被释放，之后可能被其他文件或 socket 再次使用。\n4.2 创建并关联内核 socket 状态 应用程序用 fd 查找内核中的 socket 对象。TCP 相关对象会逐步保存：\n本地IP 本地端口 远端IP 远端端口 协议类型 TCP状态 发送缓冲区 接收缓冲区 序号和确认号 窗口及重传信息 课堂中把这组 TCP 连接状态概括为 TCB（Transmission Control Block，传输控制块）。\n五元组是：\n源IP 源端口 目的IP 目的端口 传输层协议 例如：\n192.168.1.10:52000 → 192.168.1.20:2000 → TCP 同一个服务端端口可以同时拥有大量连接，因为每条连接的五元组不同。\n五、bind()：把本地地址写入 socket 课堂代码：\nbind(fd,); bind() 的作用是给 socket 指定本地 IP 和本地端口：\n通过fd找到内核socket对象 ↓ 设置本地IP ↓ 设置本地端口 服务端必须有稳定的监听地址，所以通常显式绑定：\n0.0.0.0:2000 客户端通常直接调用 connect()；如果之前没有 bind()，内核会自动选择本地地址和临时端口。\n六、listen()：让 socket 进入监听状态 课堂代码：\nlisten(fd,backlog); listen() 将 socket 变成监听 socket：\n普通TCP socket ↓ listen() LISTEN状态 如果服务端只完成 bind() 而没有 listen()，对应端口没有处于 TCP 监听状态，客户端连接通常会被拒绝。\n6.1 两类连接队列 监听 socket 的连接可以从两个阶段理解：\n收到客户端SYN ↓ SYN队列：握手尚未完成 ↓ 收到第三次握手ACK ↓ 已完成连接队列：等待应用accept() 常见名称：\nSYN queue / 半连接队列 accept queue / 已完成连接队列 “半连接”不是只能传一半数据，而是三次握手还没有完成。\n6.2 backlog 现代 Linux 中，listen(fd, backlog) 的 backlog 主要限制等待 accept() 的已完成连接队列长度：\nlisten(fd,backlog); 可以理解成：\n应用程序暂时没来得及 accept 时，内核允许多少条已经建立完成的连接排队等待。\nSYN 队列还有独立内核配置：\ncat /proc/sys/net/ipv4/tcp_max_syn_backlog 实际队列长度还受内核上限、系统配置和 SYN cookies 等机制影响。\n七、TCP 三次握手 三次握手由内核 TCP 协议栈完成。应用程序通过 connect() 发起；服务端 listen() 后由内核接收握手；accept() 取出的是已经完成握手的连接。\n假设双方初始序号分别是：\n客户端初始序号：1234 服务端初始序号：5647 7.1 第一次：客户端发送 SYN client → server SYN = 1 SEQ = 1234 含义：\n客户端希望建立连接，自己的初始发送序号从 1234 开始。\n客户端状态：\nCLOSED → SYN_SENT SYN 会占用一个序号，所以服务端确认时使用 1234 + 1。\n7.2 第二次：服务端返回 SYN + ACK server → client SYN = 1 ACK = 1 SEQ = 5647 ACK number = 1235 ACK number = 1235 表示客户端序号 1234 的 SYN 已经收到，下一次期望从 1235 开始。\n同时，服务端告诉客户端自己的初始发送序号是 5647。\n服务端相关连接状态：\nLISTEN → SYN_RECEIVED 7.3 第三次：客户端返回 ACK client → server ACK = 1 ACK number = 5648 5648 = 5647 + 1，表示服务端的 SYN 已经收到。随后双方进入：\nESTABLISHED 完整过程：\n客户端 服务端 SYN, SEQ=1234 ───────────────────────────────→ SYN+ACK, SEQ=5647, ACK=1235 ←─────────────────────────────── ACK, ACK=5648 ───────────────────────────────→ 双方进入ESTABLISHED 7.4 为什么要交换序号 双方分别告诉对方自己的初始序号，后续 TCP 才能利用序号和确认号实现：\n识别数据顺序 发现数据缺失 排除重复数据 确认已经收到的字节范围 进行超时重传 初始序号不是固定从 0 开始，而是由内核生成。这样可以减少旧连接延迟报文的干扰，也提高序号的不可预测性。\n八、connect()、握手和 accept() 的关系 客户端：\nconnect(); connect() 让内核发起 TCP 三次握手。阻塞 socket 上，connect() 通常要等待连接成功或失败后才返回，因此服务端不可达时可能等待较长时间。\n服务端：\nlisten(); accept(); 三次握手不是由 accept() 执行的。握手由服务端内核协议栈在监听状态下完成：\n内核完成三次握手 ↓ 连接进入已完成连接队列 ↓ listenfd出现可读事件 ↓ 应用调用accept() accept() 主要完成：\n从已完成连接队列取出一条连接 ↓ 为它分配新的clientfd ↓ 让clientfd关联这条已连接socket 因此：\nlistenfd → 继续监听新连接 clientfd → 与某个客户端收发数据 8.1 Reactor 中的对应关系 listenfd出现EPOLLIN ↓ accept_cb() ↓ accept() ↓ 得到clientfd ↓ 把clientfd注册到epoll LT 下，如果队列中还有连接，监听 fd 会继续报告可读。\nET 下应循环 accept()，直到当前队列已经取空。课堂代码：\nwhile（1）{ fd =accept(); if(fd==-1){ break } } 这段代码表达的核心是：\n一次EPOLLIN通知 ↓ 连续accept ↓ 把当前已经完成的连接全部取出 实际使用非阻塞 fd 时，accept() 返回 -1 且 errno 为 EAGAIN/EWOULDBLOCK，表示本轮队列已经取空。\n九、SYN 队列如何匹配第三次握手 收到第三次握手报文后，内核需要找到它属于哪一条正在建立的连接。\nIP 头和 TCP 头能够提供：\n源IP 目的IP 源端口 目的端口 协议类型TCP 内核结合五元组、序号和连接状态查找对应的半连接状态，验证 ACK 后，将连接推进到已建立状态并放入已完成连接队列。\n连接状态的生命周期不是从 accept() 才开始。服务端收到 SYN 后，内核已经需要保存握手状态；accept() 只是把完成后的连接交给应用程序。\n十、SYN Flood SYN Flood 的基本过程：\n攻击者发送大量SYN ↓ 服务端为握手保存状态 ↓ 攻击者不完成第三次握手 ↓ 半连接状态和相关资源被持续占用 常见防护包括：\nSYN cookies。 调整 SYN 队列和重试参数。 防火墙、负载均衡器和云安全组。 限速和异常源识别。 DDoS 清洗服务。 listen() 的 backlog 主要用于已完成连接队列，不能单独解决 SYN Flood。\n十一、send() 和 recv() 到底做了什么 课堂中的数据路径：\n发送方application ↓ send/write 发送方kernel TCP协议栈 ↓ 网络 ↓ 接收方kernel TCP协议栈 ↓ recv/read 接收方application 11.1 send() 课堂例子：\nsend（fd,buffer,100,0）; send（fd,buffer,200,0）; send（fd,buffer,400,0）; send() 返回正数，表示本次有多少字节被内核 socket 发送路径接受。它不表示对端应用已经读取这些数据。\n后续什么时候组成 TCP 段、什么时候交给 IP 层、何时重传，由内核协议栈处理。\nTCP 是字节流，不保留应用层每次 send() 的边界。上面三次调用总共交给 TCP 700 字节，接收端不一定按照 100、200、400 三次收到。\n11.2 recv() 课堂例子：\nrecv（fd，buffer，50,0） recv（fd，buffer，250,0） recv（fd，buffer，250,0） recv（fd，buffer，150,0） 接收端可以按照 50 + 250 + 250 + 150 取出相同的 700 字节。\nrecv() 把已经到达 socket 接收缓冲区的数据复制到应用层 buffer。一次返回多少字节取决于：\n当前已经到达多少数据 应用提供的buffer有多大 socket是否阻塞 协议栈和调度时机 因此：\n发送端三次send 不等于 接收端必须三次recv 这也是 TCP 半包、粘包问题的来源。应用层协议需要自己定义消息边界，例如固定长度、长度字段或分隔符。\n11.3 一次接收 1000 与十次接收 100 课堂对比：\nrecv（fd,buffer,1000,0）; 与：\nrecv（fd,buffer,100,0）; // 循环10次 复制总数据量可以相同，但十次 recv() 会产生更多系统调用、循环判断和状态处理开销，因此性能并非完全没有差异。\n实际代码应以协议完整性和程序结构为主，选择合理缓冲区大小，不必把每次读取切得非常小。\n十二、MTU、MSS 与 TCP 分段 MTU 是链路能够承载的最大 IP 包大小。常见以太网 MTU 是 1500 字节，但它包含 IP 头，不等于 TCP 应用数据长度。\nTCP 常用 MSS 表示一个 TCP 段最多携带的 payload 大小：\nMSS ≈ MTU - IP头 - TCP头 常见 IPv4、无额外选项时：\n1500 - 20 - 20 = 1460字节 课堂例子：\nsend（fd,buffer,1700,0）; 应用一次交给内核 1700 字节，不代表网络上一定只有一个包。协议栈会根据 MSS、网卡卸载、路径 MTU 等条件分段。\n接收端 recv() 的边界与网络分段也没有一一对应关系。网络上传输多个 TCP 段，接收端可能一次取出合并后的数据，也可能分多次取出。\n十三、TCP 如何可靠并尽量快速地传输 这些机制主要运行在两端内核 TCP 协议栈之间，不由应用程序逐包控制。\n13.1 序号与确认号 SEQ → 当前报文携带的数据从哪个字节序号开始 ACK → 下一次期望收到哪个字节序号 如果 ACK number = 1235，可以理解为序号 1235 之前的数据已经连续收到，下一次希望从 1235 开始。\n13.2 接收窗口与滑动窗口 接收方通过窗口告诉发送方：\n当前接收缓冲区还允许接收多少数据。\n发送方可以在没有逐段等待 ACK 的情况下发送窗口范围内的多段数据。ACK 推进后，允许发送的范围继续向前滑动。\n它解决：\n既要连续发送提高吞吐 又不能超过接收方处理能力 13.3 慢启动 连接开始时，发送方不知道网络能承受多少在途数据，因此从较小的拥塞窗口开始。在慢启动阶段，拥塞窗口按每个 RTT 观察通常接近指数增长。\n13.4 拥塞避免 拥塞窗口达到阈值后，增长转为更平缓的方式。检测到丢包或拥塞信号后，具体如何降低窗口取决于拥塞控制算法和丢包检测方式，不能把所有情况都理解为固定“减半重新开始”。\n13.5 延迟确认 接收方可以短暂等待，把 ACK 与反向数据一起发送，或者用一个 ACK 确认更多数据，以减少纯 ACK 包数量。\n延迟时间由协议实现和当前状态决定，不是所有系统都固定延迟 200 ms。\n13.6 超时重传与快速重传 发送方在规定时间内没有收到确认，会认为数据可能丢失并触发重传。连续收到重复 ACK 时，也可能在超时前触发快速重传。\n这些机制共同实现：\n可靠传输 按序交付 避免重复 适应接收端能力 适应网络拥塞程度 十四、close() 与四次挥手 课堂代码：\nclose(fd); close() 首先是通用的 fd 关闭接口。对 TCP socket 来说，当最后一个引用被关闭时，内核会根据 socket 状态和配置推进 TCP 关闭过程。\nTCP 关闭使用 FIN 标志，不是 final。\n14.1 为什么通常是四次 TCP 是全双工连接，两个方向需要分别关闭：\n主动关闭方 被动关闭方 FIN ───────────────────────────────→ ACK ←─────────────────────────────── FIN ←─────────────────────────────── ACK ───────────────────────────────→ 过程：\n1. 主动方发送FIN：我没有更多数据要发送 2. 被动方返回ACK：我知道你不再发送 3. 被动方处理完自己的发送后，也发送FIN 4. 主动方返回ACK：确认对方也不再发送 第二步和第三步有时会合并在同一个报文中，所以抓包时不一定总能机械地看到四个独立数据包。\n14.2 主动关闭方状态 ESTABLISHED ↓ close()，发送FIN FIN_WAIT_1 ↓ 收到对方对FIN的ACK FIN_WAIT_2 ↓ 收到对方FIN，返回ACK TIME_WAIT ↓ 等待2MSL CLOSED 14.3 被动关闭方状态 ESTABLISHED ↓ 收到FIN，返回ACK CLOSE_WAIT ↓ 本地应用调用close()，发送FIN LAST_ACK ↓ 收到对方ACK CLOSED 14.4 为什么 recv() 返回 0 被动方收到 FIN 后，如果此前还有已到达的数据，应用应先读出这些数据。全部读完以后，再次调用：\nrecv(fd,buffer,size,0); 会返回：\n0 它表示对方正常关闭了发送方向，后面不会再有数据。非阻塞 socket 暂时没有数据返回的是 -1/EAGAIN，不是 0。\n14.5 数据与 FIN 应用先调用：\nsend（fd,buffer,100,0） 随后调用：\nclose(fd); TCP 会保持字节流顺序：内核已经接受的待发送数据排在 FIN 之前。接收端先读到数据，数据全部读完后才观察到 EOF，即 recv() == 0。\n14.6 shutdown() shutdown() 可以只关闭 TCP 的一个方向：\nSHUT_RD → 关闭读方向 SHUT_WR → 关闭写方向，向对端发送FIN SHUT_RDWR → 关闭读写方向 它适合“我已经发送完，但还要继续接收对方响应”的半关闭协议。普通请求响应程序只使用 close() 也很常见，但 shutdown() 并不是没有用途。\n十五、TIME_WAIT、CLOSING 与同时关闭 15.1 TIME_WAIT 通常由最终发送 ACK 的主动关闭方进入 TIME_WAIT。作用：\n如果最后一个ACK丢失，可以重新确认对方重发的FIN 让旧连接的延迟报文在网络中消失 服务端出现大量 TIME_WAIT，通常说明服务端经常主动关闭连接，不等于双方一定同时调用了 close()。\n15.2 同时关闭 如果双方几乎同时调用 close()：\n双方都发送FIN ↓ 双方都可能在FIN_WAIT_1时收到对方FIN ↓ 进入CLOSING ↓ 收到自己FIN对应的ACK ↓ 进入TIME_WAIT 简化状态：\nESTABLISHED ↓ 本地发送FIN FIN_WAIT_1 ↓ 先收到对端FIN CLOSING ↓ 收到ACK TIME_WAIT 如果主动方先收到 ACK，再收到 FIN，则通常经过：\nFIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT TCP 状态机用于处理这些不同的报文到达顺序。\n十六、同时打开与 TCP P2P 常规应用使用客户端/服务端角色：\n客户端调用connect() 服务端调用listen()/accept() 但 TCP 建立后，双方都是全双工通信端点。客户端和服务端主要描述谁主动连接、谁被动监听，以及应用协议中的角色。\nTCP 还定义了“同时打开”：双方都主动发送 SYN，并在 SYN_SENT 状态收到对方 SYN。\n双方CLOSED ↓ 同时主动打开 双方SYN_SENT ↓ 收到对方SYN并返回SYN+ACK 双方SYN_RECEIVED ↓ 收到ACK 双方ESTABLISHED 课堂中的 P2P 方向：\nfd=socket（） bind（）//optional connect（）； 真正跨公网实现 P2P 还需要考虑 NAT、端口映射、防火墙和连接协调。单纯同时调用 connect() 不保证一定能穿透网络设备。\n本节需要理解：\nTCP 建立后没有“只能客户端发、只能服务端收”的限制，双方都可以 send 和 recv。\n十七、把 API 与 TCP 状态对应起来 应用调用或事件 内核中的主要含义 socket() 创建 fd 和 socket 相关内核对象 bind() 设置本地 IP 和端口 listen() 进入 LISTEN，准备接收连接 connect() 主动发起三次握手 收到 SYN 创建或查找握手状态，进入 SYN_RECEIVED 握手完成 连接进入 ESTABLISHED，并等待 accept accept() 从已完成连接队列取出连接，返回 clientfd send() 把应用字节交给 socket 发送路径 recv() 把 socket 接收数据复制到应用缓冲区 收到 FIN 对端关闭发送方向，最终让 recv 返回 0 close() 释放 fd 引用，并推动 TCP 关闭过程 完整主线：\nsocket() ↓ bind() ↓ listen() / connect() ↓ 三次握手 ↓ accept()得到clientfd ↓ send()/recv() ↓ 序号、确认、窗口、拥塞控制、重传 ↓ close() ↓ FIN/ACK与四次挥手 ↓ TIME_WAIT或CLOSED 十八、本节课需要真正掌握的内容 重点一：API 是入口，协议栈完成真正的 TCP 工作 connect()不是应用自己发送三个包 accept()不负责执行三次握手 send()不等于对端应用已经收到 recv()不对应某一次send() close()会推动TCP关闭状态机 重点二：监听 fd 和客户端 fd 对应不同状态 listenfd → LISTEN状态 → 管理握手和等待accept的连接 clientfd → 对应某条已建立TCP连接 → 用于send和recv 重点三：TCP 是可靠字节流 没有应用消息边界 使用序号和ACK保证顺序与可靠性 使用接收窗口进行流量控制 使用拥塞窗口适应网络承载能力 使用重传恢复丢失数据 重点四：建立和关闭都是状态机 三次握手： CLOSED → SYN_SENT/SYN_RECEIVED → ESTABLISHED 主动关闭： ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT 被动关闭： ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED 课后实践 准备两台虚拟机，观察 TCP P2P：\n1. 双方创建TCP socket 2. 根据实验设计绑定本地地址 3. 协调双方连接时机 4. 抓包观察SYN、SYN+ACK、ACK 5. 建立后让双方都执行send和recv 6. 同时或分别close，观察TCP状态 可以配合：\nss -ant tcpdump -nn -i any tcp ","date":"2026-08-10T00:00:00+08:00","image":"/MyBlog/p/posix-api-tcp-connection-lifecycle/cover.svg","permalink":"/MyBlog/p/posix-api-tcp-connection-lifecycle/","title":"POSIX API 与 TCP 连接生命周期"},{"content":" 本篇承接已有的网络 I/O、epoll 与 Reactor 笔记。这份笔记重点理解：为什么需要协程、协程怎样把异步 I/O 写成同步流程、上下文切换的三种实现方式，以及 yield、resume 和调度器之间的关系。\n一、这份笔记要解决的问题 以开源协程库 ntyco 为参照，尝试逐步理解并实现一套用户态协程框架。\n完整学习路线包括：\n1. 为什么需要协程 2. 协程有哪些原语操作 3. 如何定义 struct coroutine 4. 如何定义 struct scheduler 5. 调度器采用什么策略 6. 如何兼容或封装 POSIX API 7. 协程从创建到退出的执行流程 8. 协程如何扩展到多核 9. 如何测试协程性能 当前真正落到代码上的内容主要是：\n异步回调为什么难写 ↓ 协程如何保留同步代码结构 ↓ 上下文切换需要保存什么 ↓ setjmp/longjmp ↓ ucontext ↓ yield / resume ↓ 最简单的轮询调度器 后续的 struct coroutine、struct scheduler、I/O 调度、多核模式和性能测试，是建立在这些基础之上的下一阶段。\n二、协程要解决什么问题 2.1 同步请求 同步代码通常按顺序执行：\nfunc(){ send（）； recv(); } 执行过程：\nsend()发起请求 ↓ recv()等待结果 ↓ 结果返回以后继续执行 优点是代码符合人的思考顺序：\n发请求 → 等结果 → 处理结果 缺点是等待 I/O 时，当前执行单元不能继续做其他工作。\n2.2 异步回调 异步写法可以表示为：\ncallback(){ recv(); send(fd,cb); } func{ send(fd,callback) } 执行过程：\nfunc()发起请求 ↓ 立即返回，不阻塞当前流程 ↓ fd就绪 ↓ 事件系统调用callback() ↓ callback()继续处理结果 异步的关键不是函数名字叫 async，而是：\n发起操作和处理结果不在同一次连续调用栈中，中间通过事件和回调重新进入业务逻辑。\n2.3 异步不等于并行 异步表示等待期间可以处理其他任务；并行表示多个任务在同一时刻由不同 CPU 核执行。\n异步 → 关注等待关系 并行 → 关注是否同时执行 单线程 epoll 也可以实现异步，但它并没有同时使用多个 CPU 核。\n2.4 协程的目标 回调的性能模型很好，但业务流程一复杂，就会出现多层嵌套：\n请求A完成 → 回调中发起请求B → B的回调中发起请求C → C的回调继续业务 协程希望实现：\n底层：仍然使用异步事件和非阻塞I/O 上层：代码看起来像同步顺序执行 即：\n底层异步，上层同步写法。\n三、线程池异步与单线程同步的对比 线程池任务示例：\nclient_t *rClient = (client_t*)malloc(sizeof(client_t)); memset(rClient, 0, sizeof(client_t));\trClient-\u0026gt;fd = clientfd; job_t *job = malloc(sizeof(job_t)); job-\u0026gt;job_function = client_job; job-\u0026gt;user_data = rClient; workqueue_add_job(\u0026amp;workqueue, job); 这里把 clientfd 封装进任务：\nrClient-\u0026gt;fd = clientfd ↓ job-\u0026gt;job_function = client_job ↓ job-\u0026gt;user_data = rClient ↓ workqueue_add_job() ↓ 工作线程取出任务并执行 两种结构如下。\n3.1 同一个线程检测和处理 I/O while(1){ int nready =epll_wait(); for(int i=0;i\u0026lt;nready;i++){ recv(event[i].data.fd,buffer) send(event[i]data.fd,buffer) } } } //单线程 流程：\nepoll_wait() ↓ 遍历就绪fd ↓ 当前线程执行recv/send ↓ 处理完成后才能继续下一个事件 3.2 epoll 线程只分发任务 void *task_callback(void *arg){ recv(event[i].data.fd,buffer) send(event[i]data.fd,buffer) } while(1){ int nready =epll_wait(); for(int i=0;i\u0026lt;nready;i++){ task.fd=event[i].fd task.callback=task_callback push_task_to_threadpool(\u0026amp;task); }//多线程 流程：\nepoll_wait()检测就绪 ↓ 把fd包装成task ↓ 推入线程池 ↓ 工作线程执行recv/send 一次示例测试得到：\n线程池方式：每1000连接约1500ms 直接处理：每1000连接约7300ms 这个结果只能说明当前代码、机器和负载下，任务并行处理改善了吞吐。它不能直接推导出“所有异步程序一定比同步程序快六倍”。\n性能差异还可能来自：\n任务中是否有阻塞操作 CPU核心数量 连接和请求模型 锁与队列开销 日志输出 测试方法 数据大小 协程与线程池也不是同一种实现：\n线程池 → 内核线程并行执行任务 协程 → 通常在线程内由用户态调度器协作切换 四、为什么互联网业务需要并发等待 一个网页可能需要同时请求多个动态接口：\n用户信息 文章列表 消息数量 推荐内容 广告数据 权限信息 如果完全串行：\n请求1完成 ↓ 请求2完成 ↓ 请求3完成 总等待时间会累积。\n如果这些请求相互独立，可以在等待某个请求时推进其他请求：\n发起请求1 ─┐ 发起请求2 ─┼→ 分别等待 → 谁先完成先处理谁 发起请求3 ─┘ 同样的模型也会出现在：\nDNS查询 HTTP请求 数据库查询 Redis请求 Kafka或MongoDB访问 文件I/O 多个客户端连接 如果请求之间存在依赖：\nA的结果决定B的参数 B的结果决定C的参数 业务顺序仍然必须是 A → B → C。协程不能消除真实依赖，但可以在 A 等待期间调度其他不相关协程。\n五、从回调流程演化到协程流程 5.1 回调写法 三个请求流程可以写成：\n请求流程1 callback(){ recv(); send(fd,cb); } func{ send_http(fd,callback) switch(1,2) } 请求流程2 callback(){ recv(); send(fd,cb); } func{ switch(2,3) send(fd,callback) } 请求流程3 callback(){ recv(); send(fd,cb); } func{ send(fd,callback) switch(3,1) } 业务被拆散到多个 callback 中，切换关系也混在业务代码里。\n5.2 协程期望的写法 希望得到的目标形式：\n请求流程1 func{ setjmp(); asyn_send(fd,callback) asyn_recv(fd,buffer) } 其他请求也保持相同的顺序结构：\n请求流程2 func{ asyn_send(fd,callback) asyn_recv(fd,buffer) } 请求流程3 func{ asyn_send(fd,callback) asyn_recv(fd,buffer) } 表面看起来仍然是：\n发送 ↓ 接收 ↓ 处理 但 asyn_recv() 发现 fd 尚未就绪时，不阻塞整个线程，而是让出当前协程：\nasync_xxx(){ if(1==poll(fd,0)){ switch(); } } 它表达的思想是：\nI/O已经就绪 → 当前协程继续执行 I/O尚未就绪 → 保存当前协程上下文 → yield到调度器 → 调度其他协程 → fd就绪后resume回来 所以协程不是把阻塞 I/O 变快，而是在等待发生时切走。\n六、协程必须具备哪些原语 最基础的协程原语可以概括为：\ncreate → 创建协程 resume → 从调度器进入协程 yield → 协程主动让出执行权 exit → 协程执行结束 switch → 保存一个上下文并恢复另一个上下文 与 I/O 调度结合后还需要：\nwait_io(fd, event) → 当前协程等待某个fd事件 wakeup(coroutine) → fd就绪后把协程变为可运行 schedule() → 选择下一个可运行协程 三者之间的关系：\nresume和yield → 面向协程业务语义 switch → 底层上下文切换动作 schedule → 决定下一次resume谁 七、上下文到底是什么 协程执行到一半切走，之后还要从原位置继续，因此必须保存执行现场。\n上下文通常包括：\n程序计数器/指令位置 栈指针 通用寄存器 部分调用约定要求保存的寄存器 协程自己的栈 协程状态 如果只记住“执行到了哪一行”，但没有保存栈和寄存器，函数局部变量、返回地址和调用关系就无法恢复。\n栈式协程通常需要：\n每个协程一个独立栈 每个协程一份上下文 一个调度器上下文 I/O 模型中常见的是：\nfd就绪事件 → 找到等待这个fd的协程 → 将协程加入ready队列 并不是 fd 本身天然等于协程；实现可以选择“一连接一协程”“一请求一协程”，也可以让一个协程等待多个事件。\n八、实现上下文切换的三种方式 常见的实现方式：\n1. setjmp / longjmp 2. ucontext 3. 汇编保存和恢复寄存器，例如 ntyco 8.1 setjmp / longjmp setjmp() 保存可以恢复的执行环境，longjmp() 跳回该位置。\n它适合帮助理解“保存位置并跳回来”，但它本身不自动为每个协程创建独立栈。要实现完整栈式协程，还要管理协程栈和更完整的上下文。\n8.2 ucontext ucontext 提供：\ngetcontext() → 获取当前上下文 makecontext() → 为上下文指定入口函数 swapcontext() → 保存当前上下文并切换到目标上下文 setcontext() → 直接恢复某个上下文 它非常适合教学，因为能够明确看到栈、入口函数和上下文切换。\nucontext 已从 POSIX.1-2008 中移除，不适合作为现代跨平台库的统一标准接口，但很多 Unix/Linux 环境仍然提供它。\n8.3 汇编切换 汇编实现会按照目标架构和调用约定，直接保存、恢复必要寄存器和栈指针。\n优点：\n可控制保存哪些寄存器 切换路径短 适合高性能协程库 代价：\n与CPU架构相关 与ABI和调用约定相关 移植成本更高 实现错误容易破坏栈 ntyco 采用汇编方式实现底层上下文切换。\n九、setjmp / longjmp 原代码 setjmp / longjmp 示例代码：\n#include\u0026lt;setjmp.h\u0026gt; #include\u0026lt;stdio.h\u0026gt; jmp_buf env;//上下文环境 void func(int arg){ printf(\u0026#34;func: %d\\n\u0026#34;,arg); longjmp(env,++arg); } int main(){ int ret=setjmp(env); if(ret==0){ func(ret); }else if(ret==1){ func(ret); }else if(ret==2){ func(ret); }else if(ret==3){ func(ret); } return 0; } 9.1 jmp_buf jmp_buf env;//上下文环境 env 用来保存 setjmp() 所需的执行环境。\n9.2 第一次 setjmp() int ret=setjmp(env); 直接调用 setjmp() 时返回 0：\nret = 0 ↓ 进入if(ret==0) ↓ func(0) 9.3 longjmp() longjmp(env,++arg); func(0) 中 arg 先变成 1，再跳回 setjmp() 保存的位置。\n这一次 setjmp() 看起来像重新返回，并得到：\nret = 1 于是进入：\n}else if(ret==1){ func(ret); } 执行过程：\nsetjmp首次返回0 ↓ func(0)打印func: 0 ↓ longjmp(...,1) setjmp恢复后返回1 ↓ func(1)打印func: 1 ↓ longjmp(...,2) setjmp恢复后返回2 ↓ func(2)打印func: 2 ↓ longjmp(...,3) setjmp恢复后返回3 ↓ func(3)打印func: 3 ↓ longjmp(...,4) setjmp恢复后返回4 ↓ 没有匹配分支，main结束 预期打印：\nfunc: 0 func: 1 func: 2 func: 3 这段代码展示的是控制流跳转，还不是完整的多协程调度器。\n十、ucontext 原代码：三个协程和 main 上下文 ucontext 示例代码：\n#include\u0026lt;stdio.h\u0026gt; #include\u0026lt;ucontext.h\u0026gt; ucontext_t ctx[3]; ucontext_t main_ctx; int count=0; //coroutine1 void func1(void){ while(count++\u0026lt;30){ printf(\u0026#34;1\\n\u0026#34;); //swapcontext(\u0026amp;ctx[0],\u0026amp;ctx[1]); swapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); printf(\u0026#34;4\\n\u0026#34;); } } //coroutine2 void func2(void){ while(count++\u0026lt;30){ printf(\u0026#34;2\\n\u0026#34;); //swapcontext(\u0026amp;ctx[1],\u0026amp;ctx[2]); swapcontext(\u0026amp;ctx[1],\u0026amp;main_ctx); printf(\u0026#34;5\\n\u0026#34;); } } //coroutine3 void func3(void){ while(count++\u0026lt;30){ printf(\u0026#34;3\\n\u0026#34;); //swapcontext(\u0026amp;ctx[2],\u0026amp;ctx[0]); swapcontext(\u0026amp;ctx[2],\u0026amp;main_ctx); printf(\u0026#34;6\\n\u0026#34;); } } //schedule int main(){ char stack1[2048]={0}; char stack2[2048]={0}; char stack3[2048]={0}; getcontext(\u0026amp;ctx[0]); ctx[0].uc_stack.ss_sp=stack1; ctx[0].uc_stack.ss_size=sizeof(stack1); ctx[0].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[0],func1,0); getcontext(\u0026amp;ctx[1]); ctx[1].uc_stack.ss_sp=stack2; ctx[1].uc_stack.ss_size=sizeof(stack2); ctx[1].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[1],func2,0); getcontext(\u0026amp;ctx[1]); ctx[2].uc_stack.ss_sp=stack3; ctx[2].uc_stack.ss_size=sizeof(stack3); ctx[2].uc_link=\u0026amp;main_ctx; makecontext(\u0026amp;ctx[1],func3,0); printf(\u0026#34;swapcontext\\n\u0026#34;); swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[0]); int i=30; while(count\u0026lt;30){ swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[count%3]); } printf(\u0026#34;\\n\u0026#34;); return 0; } 这段代码希望建立：\nmain_ctx → 调度器上下文 ctx[0] → func1协程 ctx[1] → func2协程 ctx[2] → func3协程 阅读第三个协程的初始化时，要按照这个对应关系理解：\nctx[2]使用stack3 ctx[2]的入口函数是func3 十一、如何创建一个 ucontext 协程 以第一个协程为例。\n11.1 保存初始上下文 getcontext(\u0026amp;ctx[0]); ctx[0] 获得一份可继续配置的上下文。\n11.2 指定独立栈 ctx[0].uc_stack.ss_sp=stack1; ctx[0].uc_stack.ss_size=sizeof(stack1); 含义：\nstack1 → 栈起始地址 sizeof(stack1) → 栈大小 ctx[0].uc_stack → ctx[0]使用的协程栈 三个协程分别使用：\nctx[0] → stack1 ctx[1] → stack2 ctx[2] → stack3 独立栈让不同协程能够保存自己的函数调用关系和局部变量。\n11.3 指定返回去向 ctx[0].uc_link=\u0026amp;main_ctx; 如果 func1 正常执行完并返回，运行时会转到 main_ctx。\nuc_link 处理的是入口函数正常返回后的去向；func1 中主动调用 swapcontext() 则是显式 yield。\n11.4 指定入口函数 makecontext(\u0026amp;ctx[0],func1,0); 含义：\nctx[0]第一次被resume时 ↓ 从func1开始执行 ↓ func1没有参数 最后一个 0 表示传给 func1 的参数数量为 0。\n十二、swapcontext()：保存当前上下文并恢复另一个 函数形式：\nswapcontext(\u0026amp;from,\u0026amp;to); 可以理解成：\n把当前执行现场保存到from ↓ 恢复to中的执行现场 ↓ 开始运行to 12.1 main resume 第一个协程 swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[0]); 含义：\n保存main当前执行位置到main_ctx ↓ 恢复ctx[0] ↓ 第一次进入func1 func1 打印：\nprintf(\u0026#34;1\\n\u0026#34;); 12.2 func1 yield 回 main swapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); 含义：\n保存func1当前执行位置到ctx[0] ↓ 恢复main_ctx ↓ main从刚才swapcontext之后继续 func1 的执行位置停在：\nswapcontext(\u0026amp;ctx[0],\u0026amp;main_ctx); 下一次 main 再 resume ctx[0] 时，swapcontext() 返回，继续执行：\nprintf(\u0026#34;4\\n\u0026#34;); 这就是协程能够“从上次让出的位置继续”的原因。\n十三、yield、resume 和 switch 三者的对应关系：\nmain → ctx[0] → resume协程 ctx[0] → main → yield协程 swapcontext() → 底层switch动作 13.1 resume 调度器选择一个协程 ↓ 从main_ctx切到该协程ctx ↓ 协程开始或继续运行 13.2 yield 协程遇到I/O等待或主动让出 ↓ 保存自己的ctx ↓ 切回main_ctx ↓ 调度器选择其他协程 13.3 switch switch 不关心为什么切换，只负责：\n保存from 恢复to resume 和 yield 是对 switch 在不同业务方向上的命名。\n十四、当前调度器如何工作 原代码：\nwhile(count\u0026lt;30){ swapcontext(\u0026amp;main_ctx,\u0026amp;ctx[count%3]); } count % 3 的结果不断在 0、1、2 之间变化：\ncount % 3 == 0 → resume ctx[0] count % 3 == 1 → resume ctx[1] count % 3 == 2 → resume ctx[2] 因此它相当于一个简单轮询调度器：\nmain ↓ ctx[0]运行一段后yield ↓ main ↓ ctx[1]运行一段后yield ↓ main ↓ ctx[2]运行一段后yield ↓ main继续轮询 这些上下文的栈关系可以画成：\nctx3独立栈 ctx2独立栈 ctx1独立栈 main栈 这些栈并不是垂直嵌套成一条普通函数调用链，而是各自保存执行现场，由 swapcontext() 在它们之间切换。\n十五、为什么当前轮询还不是 I/O 调度器 当前策略只根据：\nctx[count%3] 选择协程，它不知道：\n哪个协程正在等待fd 哪个fd已经就绪 哪个协程已经结束 哪个协程可以立即运行 真正的 I/O 协程调度需要至少维护：\nready队列 → 当前可以运行的协程 waiting集合 → 等待I/O、定时器或条件的协程 epoll → 检测fd就绪 协程状态 → NEW、READY、RUNNING、WAITING、DEAD 典型过程：\n协程调用异步recv ↓ 数据尚未就绪 ↓ 把“fd → 当前协程”注册给调度器 ↓ 当前协程状态变成WAITING ↓ yield回调度器 ↓ 调度器resume其他READY协程 ↓ epoll报告fd可读 ↓ 对应协程进入READY队列 ↓ 调度器再次resume它 ↓ recv继续执行 这个过程才真正实现：\n代码看起来在等待 recv，但线程没有被这个协程阻塞。\n十六、协程结构体需要保存什么 进一步实现时需要定义 struct coroutine。根据前面的执行过程，它至少需要表达：\n协程ID 协程状态 上下文 独立栈和栈大小 入口函数 入口参数 所属调度器 等待的fd和事件 退出结果或错误 概念结构：\nstruct coroutine ├── id ├── state ├── context ├── stack ├── function ├── argument ├── waiting_fd └── scheduler 这里先理解字段为什么存在，不需要脱离后续 ntyco 代码自行发明另一套结构。\n十七、调度器需要保存什么 调度器负责管理所有协程并决定下一次运行谁。\n概念结构：\nstruct scheduler ├── main_context ├── current_coroutine ├── ready_queue ├── waiting_coroutines ├── epoll_fd ├── timer └── all_coroutines 调度器要回答：\n当前运行的是谁 哪些协程可以运行 哪些协程在等待I/O 哪些协程等待超时 fd就绪后应该唤醒谁 协程退出后怎样回收 十八、如何封装 POSIX API 一个重要设计目标，是让协程网络库尽量保持熟悉的 POSIX 调用方式。\n期望上层仍然写：\nconnect accept recv send close 但协程封装后的行为是：\n调用recv ↓ 先尝试非阻塞recv ├── 已就绪：直接返回结果 └── EAGAIN：注册EPOLLIN并yield ↓ fd就绪 ↓ resume协程 ↓ 再次尝试recv 这样业务代码保留同步风格，但底层仍然由 epoll 和协程调度实现异步等待。\n“接口一致”不代表直接调用阻塞版本的 POSIX API，而是协程库提供行为兼容的包装层或 hook。\n十九、协程怎样用于 WebServer 已有 Reactor WebServer 的逻辑：\nepoll_wait ↓ recv_cb ↓ http_request ↓ send_cb ↓ http_response 协程模型可以把一个连接或一个请求组织成顺序流程：\n协程开始 ↓ 等待并接收HTTP请求 ↓ 解析HTTP ↓ 查询数据库或缓存 ↓ 构造HTTP响应 ↓ 发送响应 ↓ 等待下一次请求或退出 遇到 fd 尚未就绪时：\n当前协程yield ↓ 线程继续处理其他连接协程 ↓ fd就绪后resume Reactor 与协程不是互斥关系：\nReactor/epoll → 检测事件 协程调度器 → 把事件转换成协程唤醒 协程 → 承载顺序业务流程 二十、微信群聊场景应该怎样思考 思考这个场景时，不必急着寻找固定答案，先建立分析框架。\n面对“微信群聊如何使用协程”时，可以依次问：\n1. 一个协程对应一个连接、一个用户，还是一次消息处理？ 2. 连接没有消息时，协程处于什么状态？ 3. fd可读后，谁把对应协程唤醒？ 4. 一条消息需要广播给多人时，哪些步骤能够并发？ 5. 慢客户端发送不出去时，是否会阻塞其他用户？ 6. 每个连接的发送队列放在哪里？ 7. 用户断开以后，协程、fd和缓冲区如何回收？ 8. 心跳和超时由谁管理？ 9. 群消息顺序如何保证？ 10. 多核模式下，同一个连接能否跨调度器迁移？ 可以先画：\n用户A连接协程 ↓ 收到群消息 服务端业务处理 ↓ 找到群成员 ↓ 把消息加入B/C/D的发送队列 ↓ 对应连接可写时唤醒发送流程 重点不是简单回答“一个用户一个协程”，而是明确：\n协程在哪里等待 什么事件唤醒 状态保存在哪里 慢连接如何隔离 二十一、协程的多核模式 单个调度器通常运行在一个线程中，同一时刻只能使用一个 CPU 核。\n多核模式常见思路：\n一个线程一个调度器 ↓ 每个调度器拥有自己的epoll和ready队列 ↓ 多个线程并行运行 这形成 M:N 模型：\nM个协程 ↓ 用户态调度 N个内核线程 ↓ 多个CPU核 需要处理：\n连接如何分配到各调度器 协程是否允许跨线程迁移 跨线程唤醒 共享队列和锁 负载均衡 work stealing 线程局部状态 上下文通常只能在它所属的线程和栈环境中安全切换，不能随意把 setjmp/ucontext 保存的上下文拿到另一个线程恢复。\n二十二、协程性能如何测试 不能只看一个“每 1000 连接耗时”。协程性能至少分成四层。\n22.1 原语性能 创建一个协程的耗时 一次yield/resume的耗时 每秒可以上下文切换多少次 销毁协程的耗时 22.2 内存 每个协程结构体大小 每个协程栈大小 一万或百万协程的总内存 栈是否按需增长 22.3 I/O 服务性能 并发连接数 QPS 吞吐量 P50/P95/P99延迟 超时和错误率 CPU使用率 上下文切换次数 22.4 对比基线 应在相同业务和负载下比较：\n单线程Reactor Reactor + 线程池 Reactor + 协程 一连接一线程 只有测试业务、连接数、响应大小和日志配置相同，结果才有可比性。\n二十三、核心总结 重点一：协程不是为了创造真正并行 单线程协程仍然只使用一个CPU核 协程的主要价值是等待I/O时切换任务 多核需要多个调度线程配合 重点二：协程结合了两种优点 上层：同步顺序代码，容易理解 底层：异步事件等待，不阻塞整个线程 重点三：上下文和独立栈是恢复执行的基础 上下文 → 记录执行现场 协程栈 → 保存函数调用和局部变量 switch → 保存一个上下文，恢复另一个 重点四：yield、resume 与调度器 resume → 调度器进入协程 yield → 协程返回调度器 switch → 完成底层上下文切换 scheduler → 决定下一个运行谁 重点五：真正的 I/O 协程需要 epoll recv遇到EAGAIN ↓ 协程注册等待EPOLLIN ↓ yield ↓ epoll报告fd可读 ↓ 协程进入ready队列 ↓ resume ↓ 继续recv和后续业务 最终理解：\n协程不是替代 epoll，而是建立在事件驱动之上，把回调式异步流程重新组织成可顺序阅读的业务代码。\n","date":"2026-08-10T00:00:00+08:00","image":"/MyBlog/p/coroutine-design-ucontext-scheduling/cover.svg","permalink":"/MyBlog/p/coroutine-design-ucontext-scheduling/","title":"协程设计原理：从异步回调到 ucontext 调度"},{"content":" 本篇是《网络IO》《IO 多路复用与 Reactor》《Reactor 与 epoll：百万并发网络服务器》的后续实践。\nsocket、fd、select、poll、epoll 接口、连接对象和 Reactor 基础不再重复；本篇只整理这节课新增的 LT/ET、HTTP WebServer、文件发送状态机和 WebSocket。\n一、本节课做了什么 前一节已经搭好了 Reactor 网络层，本节在它上面接入两种协议业务：\nreactor.c：网络 I/O 层 ├── recv_cb() └── send_cb() webserver.c：HTTP 协议层 ├── http_request() └── http_response() websocket.c：WebSocket 协议层 ├── ws_request() └── ws_response() 本节最重要的调用链：\nclientfd 出现 EPOLLIN ↓ recv_cb() 调用 recv() ↓ http_request() / ws_request() ↓ set_event(fd, EPOLLOUT, 0) ↓ clientfd 出现 EPOLLOUT ↓ send_cb() ↓ http_response() / ws_response() ↓ send() ↓ set_event(fd, EPOLLIN, 0) 因此，Reactor 与协议层的分工是：\nreactor.c → 关心 fd 是否可读、可写，以及 recv/send webserver.c → 关心 HTTP 请求和 HTTP 响应 websocket.c → 关心握手、帧解码和帧编码 二、LT 与 ET 2.1 LT：水平触发 只要内核接收缓冲区中还有数据，LT 就会继续报告可读事件。\n例如客户端发送 32 字节，服务端一次只接收 10 字节：\n第一次 recv：10 字节，剩余 22 第二次 recv：10 字节，剩余 12 第三次 recv：10 字节，剩余 2 第四次 recv：2 字节，接收完成 2.2 ET：边沿触发 ET 主要关注从“没有数据”到“有数据”的状态变化。收到一次通知后，应在当前回调中尽量把数据处理完。\n当前代码在 accept_cb() 中这样注册客户端：\nevent_rigister(clientfd,EPOLLIN|EPOLLET);//|EPOLLET 这表示 clientfd 使用 ET，并且先关注可读事件。\nET 的处理思路：\naccept_cb() → 持续 accept，直到当前没有新连接 recv_cb() → 持续 recv，直到当前数据读完 send_cb() → 持续推进发送，直到发完或暂时不可写 循环读取时要配合非阻塞 fd。当前没有更多数据时，recv() 返回 -1，并且 errno 为 EAGAIN 或 EWOULDBLOCK，这表示本轮读取结束，应回到事件循环。\nLT 和 ET 都不能决定 HTTP 请求或 WebSocket 帧是否完整。TCP 是字节流，协议层仍然需要根据协议格式判断消息边界。\n三、recv_cb()：从网络层进入协议层 本节使用你提交的原代码：\nint recv_cb(int fd){ memset(conn_list[fd].rbuffer,0,BUFFER_LENGTH); int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); if (count == 0) { // disconnect printf(\u0026#34;client disconnect: %d\\n\u0026#34;, fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);//unfinished return -1; }else if(count\u0026lt;0){ printf(\u0026#34;count:%d,errno:%d ,%s\\n\u0026#34;, count,errno,strerror(errno)); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);//unfinished return 0; } conn_list[fd].rlength=count; // printf(\u0026#34;RECV: %s\\n\u0026#34;, conn_list[fd].rbuffer); #if 0//echo conn_list[fd].wlength=conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer,conn_list[fd].rbuffer,conn_list[fd].wlength); printf(\u0026#34;[%d]RECV: %s\\n\u0026#34;,conn_list[fd].rlength,conn_list[fd].rbuffer); #elif0 http_request(\u0026amp;conn_list[fd]); #else ws_request(\u0026amp;conn_list[fd]); #endif set_event(fd,EPOLLOUT,0); return count; } 3.1 接收数据 int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); 数据从内核 socket 接收缓冲区复制到当前连接的 rbuffer，实际接收长度保存到：\nconn_list[fd].rlength=count; 3.2 三种业务切换 Echo 分支把收到的数据复制到写缓冲区：\n#if 0//echo conn_list[fd].wlength=conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer,conn_list[fd].rbuffer,conn_list[fd].wlength); printf(\u0026#34;[%d]RECV: %s\\n\u0026#34;,conn_list[fd].rlength,conn_list[fd].rbuffer); HTTP 分支把连接对象交给 `http_request()`： ```c #elif0 http_request(\u0026amp;conn_list[fd]); ``` WebSocket 分支把连接对象交给 `ws_request()`： ```c #else ws_request(\u0026amp;conn_list[fd]); ``` 三种业务共用同一个 `recv_cb()`，只替换上层协议处理函数。这正是 Reactor 与业务分层后的好处。 3.3 从读事件切换到写事件 set_event(fd,EPOLLOUT,0); http_request() 或 ws_request() 处理完输入后，当前 fd 改为关注 EPOLLOUT。等 socket 可写时，Reactor 会调用 send_cb()。\n四、HTTP 请求：http_request() 浏览器访问服务器时，会发送类似内容：\nGET / HTTP/1.1 Host: 172.16.145.129:2000 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Accept: text/html,application/xhtml+xml,application/xml Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9,en;q=0.8 请求由请求行、请求头、空行和可选 body 组成：\nGET / HTTP/1.1 → 请求行 Host → 请求头 Connection → 请求头 User-Agent → 请求头 空行 → 请求头结束 原代码：\nint http_request(struct conn *c){ //printf(\u0026#34;request: %s\\n\u0026#34;,c-\u0026gt;rbuffer); memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;wlength=0; c-\u0026gt;status=0; } 当前 http_request() 主要完成一次新响应的状态初始化：\nc-\u0026gt;rbuffer → 当前 HTTP 请求 c-\u0026gt;wbuffer → 清空，准备写响应 c-\u0026gt;wlength → 置 0 c-\u0026gt;status → 置 0，从响应状态机起点开始 它位于：\nrecv_cb() 之后 send_cb() 之前 五、HTTP 响应：直接返回 HTML 原代码：\nint http_response(struct conn *c){ #if 1 c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: 82\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34; \u0026#34;\u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;0voice.king\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;King\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt;\\r\\n\\r\\n\u0026#34;); return 0; 其中：\nHTTP/1.1 200 OK Content-Type: text/html Accept-Ranges: bytes Content-Length: 82 Date: Tue,30 Apr 2024 13:16:46 GMT 是 HTTP 状态行和响应头。它们告诉浏览器响应是否成功、body 是什么类型、长度是多少。\n下面是 HTTP body：\n\u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;title\u0026gt;0voice.king\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026lt;h1\u0026gt;King\u0026lt;/h1\u0026gt;\u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; 浏览器看到 Content-Type: text/html 后，会把 body 当作 HTML 渲染，页面中显示 King。\n这一分支中，HTTP 头和 body 一起写入：\nc-\u0026gt;wbuffer 总长度由 sprintf() 的返回值保存到：\nc-\u0026gt;wlength 调用链：\nrecv_cb() ↓ http_request() ↓ 设置 EPOLLOUT ↓ send_cb() ↓ http_response() ↓ HTTP 头和 HTML 写入 wbuffer ↓ send() 六、返回 index.html 原代码：\n#elif 0 int filefd =open(\u0026#34;index.html\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); int count=read(filefd,c-\u0026gt;wbuffer+c-\u0026gt;wlength,BUFFER_LENGTH-c-\u0026gt;wlength); c-\u0026gt;wlength +=count; close(filefd); 执行过程：\nopen(\u0026#34;index.html\u0026#34;) ↓ fstat() 得到文件大小 ↓ HTTP 响应头写入 wbuffer ↓ read() 把 index.html 追加在 HTTP 头后面 ↓ wlength = HTTP 头长度 + 本次文件读取长度 ↓ send_cb() 发送整个 wbuffer stat_buf.st_size 被写入：\nContent-Length: 文件大小 这个分支的特点是：HTTP 头和文件内容都先进入应用层 wbuffer，然后统一调用 send()。\n七、状态机与 sendfile() 当一个响应需要多个发送阶段时，课程使用 status 保存当前进度：\nstatus = 0 → 构造并发送 HTTP 头 status = 1 → 持续发送文件 status = 2 → 文件发送结束，清理状态 原代码：\n#elif 0 int filefd =open(\u0026#34;index.html\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); if(c-\u0026gt;status==0){ c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: text/html\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); c-\u0026gt;status=1; }else if (c-\u0026gt;status==1){ int ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); if(ret==-1){ printf(\u0026#34;error:%d\\n\u0026#34;,errno); } //c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=2; }else if(c-\u0026gt;status==2){ c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=0; } close(filefd); 状态流转：\n第一次进入 http_response() ↓ status == 0 构造 HTTP 响应头 ↓ status = 1 send_cb() 发送 wbuffer，并继续关注 EPOLLOUT ↓ 第二次进入 http_response() ↓ status == 1 sendfile() 发送 index.html ↓ status = 2 继续关注 EPOLLOUT ↓ 第三次进入 http_response() ↓ status == 2 清理 wbuffer 和 wlength ↓ status = 0 重新关注 EPOLLIN sendfile() 直接建立文件 fd 到 socket fd 的发送路径：\nint ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); 协议层仍然负责构造 HTTP 头，sendfile() 负责发送文件内容。状态机把两个阶段连接起来。\n八、返回图片 图片与 HTML 在网络层都是字节数据，主要区别是 HTTP 的 Content-Type。\n原代码：\n#else int filefd =open(\u0026#34;vip.png\u0026#34;,O_RDONLY); struct stat stat_buf; fstat(filefd,\u0026amp;stat_buf); if(c-\u0026gt;status==0){ c-\u0026gt;wlength=sprintf(c-\u0026gt;wbuffer,\u0026#34;HTTP/1.1 200 OK\\r\\n\u0026#34; \u0026#34;Content-Type: image/png\\r\\n\u0026#34; \u0026#34;Accept-Ranges: bytes\\r\\n\u0026#34; \u0026#34;Content-Length: %ld\\r\\n\u0026#34; \u0026#34;Date: Tue,30 Apr 2024 13:16:46 GMT\\r\\n\\r\\n\u0026#34;, stat_buf.st_size); c-\u0026gt;status=1; }else if (c-\u0026gt;status==1){ int ret=sendfile(c-\u0026gt;fd,filefd,NULL,stat_buf.st_size); if(ret==-1){ printf(\u0026#34;error:%d\\n\u0026#34;,errno); } //c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=2; }else if(c-\u0026gt;status==2){ c-\u0026gt;wlength=0; memset(c-\u0026gt;wbuffer,0,BUFFER_LENGTH); c-\u0026gt;status=0; } close(filefd); 与 HTML 文件的区别：\n响应内容 打开的文件 Content-Type 网页 index.html text/html PNG 图片 vip.png image/png Reactor 的 recv_cb()、send_cb() 不需要知道 body 是网页还是图片。WebServer 协议层设置正确的响应头即可。\n九、send_cb() 如何驱动多阶段发送 原代码：\nint send_cb(int fd){ #if 0 http_response(\u0026amp;conn_list[fd]); #else ws_response(\u0026amp;conn_list[fd]); #endif int count=0; if(conn_list[fd].status==1){ //printf(\u0026#34;SEND:%s\\n\u0026#34;,conn_list[fd].wbuffer); count = send(fd, conn_list[fd].wbuffer,conn_list[fd].wlength, 0); set_event(fd,EPOLLOUT,0); }else if(conn_list[fd].status==2){ set_event(fd,EPOLLOUT,0); }else if(conn_list[fd].status==0){ if(conn_list[fd].wlength !=0){ count = send(fd, conn_list[fd].wbuffer,conn_list[fd].wlength, 0); } set_event(fd,EPOLLIN,0); } //set_event(fd,EPOLLOUT,0); return count; } 9.1 选择协议响应 学习 HTTP 时，让写回调进入：\n#if 0 http_response(\u0026amp;conn_list[fd]); 学习 WebSocket 时，让写回调进入：\n#else ws_response(\u0026amp;conn_list[fd]); 9.2 状态与事件切换 status == 1 → send() 发送当前 wbuffer → 继续设置 EPOLLOUT → 等待下一阶段 status == 2 → 继续设置 EPOLLOUT → 进入状态机清理阶段 status == 0 → 如果 wlength 不为 0，就发送当前数据 → 设置 EPOLLIN → 等待下一次请求 这就是“需要发送多次时怎么办”的答案：\n保存当前发送状态，再通过后续的 EPOLLOUT 事件继续推进，而不是把整个过程强行塞进一次回调。\n十、WebSocket 握手 WebSocket 用于浏览器与服务器之间的长连接和双向通信。\n建立 WebSocket 连接时，浏览器先发送 HTTP Upgrade 请求，其中包括：\nUpgrade: websocket Connection: Upgrade Sec-WebSocket-Key: T29c4Rys861qGqbGGNdkwA== Sec-WebSocket-Version: 13 你提交的 WebSocket 代码：\n#include\u0026#34;server.h\u0026#34; #include\u0026lt;stdio.h\u0026gt; #define GUID \u0026#34;258EAFA5-E914-47DA-95CA-C5AB0DC85B11\u0026#34; /* Key：\u0026#34;T29c4Rys861qGqbGGNdkwA==\u0026#34; T29c4Rys861qGqbGGNdkwA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11 SHA-1 20 bytes base64 handshake transmission decode encode */ 服务端计算 Sec-WebSocket-Accept 的过程：\n客户端 Sec-WebSocket-Key + 固定 GUID ↓ SHA-1 ↓ 160 bit = 20 bytes ↓ Base64 ↓ Sec-WebSocket-Accept 固定 GUID：\n#define GUID \u0026#34;258EAFA5-E914-47DA-95CA-C5AB0DC85B11\u0026#34; 客户端 Key 放在前面，GUID 放在后面。\n服务端响应格式：\nHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: 计算结果 返回 101 后，同一个 TCP 连接不再传输普通 HTTP 消息，而是进入 WebSocket 帧传输阶段。\n十一、WebSocket 在 Reactor 中的位置 原代码：\nint ws_request(struct conn *c){ printf(\u0026#34;request: %s\u0026#34;,c-\u0026gt;rbuffer); } int ws_response(struct conn *c){ } 当前两个函数是课程为后续实现预留的协议接口。\nws_request() 后续负责：\n握手阶段 → 解析 HTTP Upgrade 请求和 Sec-WebSocket-Key 传输阶段 → 解析 WebSocket frame，完成 decode ws_response() 后续负责：\n握手阶段 → 构造 101 和 Sec-WebSocket-Accept 传输阶段 → 把文本或二进制业务数据 encode 成 WebSocket frame 在 Reactor 中的调用位置：\nEPOLLIN ↓ recv_cb() ↓ ws_request() ↓ 设置 EPOLLOUT ↓ send_cb() ↓ ws_response() ↓ send() WebSocket 是应用层协议，底层仍然是普通 TCP clientfd。Reactor 不需要为 WebSocket 创建一种新 fd。\n十二、使用 wrk 测试 WebServer 测试命令：\nwrk -t10 -c50 -d10s http://172.16.145.129:2000/ 课程中的测试现象：\n打印每次 request 和 Host 数据时：QPS 约 1.5 万 关闭逐请求打印后：QPS 约 4.5 万 逐请求打印会产生格式化、终端输出和系统调用开销，因此会明显降低服务器吞吐量。\nwrk 结果中主要关注：\nRequests/sec → 每秒请求数，即 QPS Latency → 请求延迟 timeout → 超时数量 read/write → 读写错误 Transfer/sec → 每秒传输数据量 局域网延迟较小；公网还会受到网络时延、丢包、带宽和中间设备影响。\n十三、Connection reset by peer Connection reset by peer 表示连接被对端通过 TCP RST 重置。\n常见情况：\n客户端提前关闭连接 压测工具结束请求 服务器准备发送时客户端已经退出 网络设备重置连接 服务器应识别错误并清理当前连接，不要让单个客户端断开导致整个进程退出。\n十四、本节课的三个重点 重点一：事件与协议函数的对应关系 clientfd + EPOLLIN → recv_cb() → http_request() / ws_request() clientfd + EPOLLOUT → send_cb() → http_response() / ws_response() 重点二：网络层与协议层分离 reactor.c → 管理 fd、事件、recv、send webserver.c → 处理 HTTP request 和 response websocket.c → 处理 handshake、decode 和 encode 重点三：多次发送使用状态机 一次发送即可完成 → wbuffer + wlength 需要多个发送阶段 → status 保存进度 → 多次 EPOLLOUT 推进 发送静态文件 → HTTP 头放入 wbuffer → 文件内容交给 sendfile() 最终理解：\nReactor 提供统一的事件驱动网络层；HTTP 和 WebSocket 负责协议，业务数据何时读取、何时发送，由 fd 的事件和回调共同驱动。\n","date":"2026-08-09T00:00:00+08:00","image":"/MyBlog/p/reactor-webserver-websocket/cover.svg","permalink":"/MyBlog/p/reactor-webserver-websocket/","title":"Reactor 在 WebServer 与 WebSocket 中的应用"},{"content":"1. 本节课要解决什么问题 本节课不是直接实现完整 WebServer，而是先搭建一个能够承载大量网络连接的底层 I/O 框架。在这个框架之上，后续才能继续实现 HTTP 解析、路由、业务处理等功能。\n核心目标有两个：\n使用 Linux epoll 管理大量 socket。 使用 Reactor 思想，把不同 I/O 事件分发给不同回调函数。 当前代码实现的是一个简单的多端口 Echo Server：客户端发来什么，服务端就回写什么。\n2. Reactor 的核心思想 Reactor 可以概括为：\nI/O 对象（fd） → 发生事件（event） → 执行回调（callback） 本例中的映射关系如下：\nI/O 对象 事件 含义 回调函数 listenfd EPOLLIN 有新连接到达 accept_cb() clientfd EPOLLIN 客户端数据可读 recv_cb() clientfd EPOLLOUT socket 当前可写 send_cb() 因此，主循环不需要判断“这个 fd 到底要做什么业务”，而只需要：\n调用 epoll_wait() 等待事件。 从事件中取得 fd。 根据事件类型，调用该 fd 已经注册好的回调。 这就是“事件驱动”：没有事件时线程阻塞等待，有事件时才处理对应连接。\n2.1 Reactor 的两个重点 建立 event 与 callback 的对应关系。 为每一个 I/O 保存独立的连接状态。 第二点非常重要。多个连接的数据不能共用一组读写缓冲区，否则连接之间会互相覆盖。因此每个 fd 都需要自己的：\n读缓冲区 rbuffer 当前读取长度 rlength 写缓冲区 wbuffer 待发送长度 wlength 读事件回调 写事件回调 3. epoll 在 Reactor 中的职责 epoll 是 Linux 提供的 I/O 多路复用机制。一个线程可以通过一个 epoll 实例监听大量 fd，而不必给每条 TCP 连接创建一个线程。\n三个最重要的接口是：\nint epoll_create1(int flags); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 其作用分别为：\nepoll_create1()：创建一个 epoll 实例。 epoll_ctl()：添加、修改或删除所关注的 fd 和事件。 epoll_wait()：等待已经注册的 fd 产生事件。 当前代码使用的是默认的 LT（Level Triggered，水平触发）模式，因为没有设置 EPOLLET。\nLT 模式下，只要 fd 仍处于可读或可写状态，epoll_wait() 就会继续报告它。若以后改成 ET（边沿触发），则 socket 必须设置成非阻塞，并且 accept()、recv() 等操作通常要循环执行，直到返回 EAGAIN。\n4. 代码中的连接对象 struct conn { int fd; char rbuffer[BUFFER_LENGTH]; int rlength; char wbuffer[BUFFER_LENGTH]; int wlength; RCALLBACK send_callback; union { RCALLBACK recv_callback; RCALLBACK accept_callback; } r_action; }; 4.1 为什么需要 struct conn TCP 是字节流协议，一次 recv() 不保证收到一个完整业务消息：\n一个完整消息可能被拆成多次接收。 多个消息也可能在一次 recv() 中一起到达。 一次 send() 也不保证发送全部待发送数据。 所以 Reactor 不仅要保存回调，还必须保存每个连接的处理进度。严格来说，后续还应增加：\n已读取但尚未解析的数据偏移量； 待发送数据的起始偏移量； 协议解析状态； 连接状态和超时信息； 动态扩容或链式缓冲区。 4.2 为什么 accept_callback 与 recv_callback 使用 union 监听 fd 的读事件表示“有连接可接受”，其回调是 accept_cb()；客户端 fd 的读事件表示“有数据可接收”，其回调是 recv_cb()。\n同一个 fd 在这里不会同时既是监听 fd 又是客户端 fd，因此两个函数指针可以共用同一块存储空间。union 表达了二者“二选一”的关系。\n不过，代码在赋值时使用了：\nconn_list[sockfd].r_action.recv_callback = accept_cb; 语义上更清晰的写法是：\nconn_list[sockfd].r_action.accept_callback = accept_cb; 调用时也应根据 fd 的角色使用对应成员，或者直接将二者合并成一个名称更通用的 read_callback。\n4.3 为什么可以用 fd 作为数组下标 当前代码使用：\nstruct conn conn_list[CONNECTION_SIZE]; 并直接通过 conn_list[fd] 找到连接。这种方法查找复杂度为 O(1)，非常直接。\n但它有两个条件：\n必须保证 0 \u0026lt;= fd \u0026lt; CONNECTION_SIZE，否则会越界。 数组内存非常大。 每个连接对象包含两个 1024 字节缓冲区，再加上长度、fd 和函数指针。100 多万个元素大约需要 2 GiB 以上的地址空间和可观的实际内存。因此“支持 100 万个 fd”不等于“采用这种结构后机器就一定能稳定承载 100 万连接”。生产实现通常会根据实际连接动态分配，或者按块分配连接对象与缓冲区。\n5. 程序完整执行流程 5.1 初始化 epoll 代码中：\nepfd = epoll_create(1); 参数在现代 Linux 中只要大于 0 即可，并不表示只能监听一个 fd。更推荐：\nepfd = epoll_create1(EPOLL_CLOEXEC); 创建失败时必须检查返回值。\n5.2 创建 20 个监听 socket for (i = 0; i \u0026lt; MAX_PORTS; i++) { int sockfd = init_server(port + i); ... } 程序监听 2000~2019 共 20 个端口。每个监听 fd 都注册 EPOLLIN，并对应 accept_cb()。\n监听 fd 上的 EPOLLIN 不是普通业务数据可读，而是表示全连接队列中存在已经完成握手、可以由 accept() 取出的连接。\n5.3 接受新连接 accept_cb() 的逻辑是：\n调用 accept()，获得 clientfd。 检查 clientfd \u0026lt; 0。 初始化 conn_list[clientfd]。 给客户端 fd 注册 EPOLLIN。 这时事件映射从：\nlistenfd + EPOLLIN → accept_cb 变成新连接上的：\nclientfd + EPOLLIN → recv_cb 5.4 接收数据 当客户端 fd 可读时执行 recv_cb()：\ncount = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0); 当前 Echo 逻辑把收到的数据复制到写缓冲区：\nconn_list[fd].wlength = conn_list[fd].rlength; memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength); 随后把关注事件修改为 EPOLLOUT：\nclientfd + EPOLLOUT → send_cb 5.5 发送数据 当 fd 可写时执行 send_cb()。数据发送结束后，再把关注事件切换回 EPOLLIN，等待客户端下一次发送。\n完整状态流转如下：\n监听 EPOLLIN ↓ accept_cb：建立 clientfd ↓ 客户端 EPOLLIN ↓ recv_cb：接收并准备响应 ↓ 客户端 EPOLLOUT ↓ send_cb：发送响应 ↓ 重新监听客户端 EPOLLIN 6. set_event() 与事件注册 int set_event(int fd, int event, int flag) 当前约定：\nflag == 1：使用 EPOLL_CTL_ADD。 flag == 0：使用 EPOLL_CTL_MOD。 功能上可行，但 flag 的含义不直观。更清晰的设计是直接传入 EPOLL_CTL_ADD、EPOLL_CTL_MOD，或者分别封装成 add_event() 和 mod_event()。\n同时必须检查 epoll_ctl() 的返回值。例如：\nif (epoll_ctl(epfd, op, fd, \u0026amp;ev) == -1) { perror(\u0026#34;epoll_ctl\u0026#34;); return -1; } set_event() 和 event_rigister() 声明为返回 int，但成功路径没有 return，这属于未定义行为。应补充 return 0。另外，rigister 应改为 register。\n7. 主事件循环 while (1) { int nready = epoll_wait(epfd, events, 1024, -1); for (i = 0; i \u0026lt; nready; i++) { int connfd = events[i].data.fd; if (events[i].events \u0026amp; EPOLLIN) conn_list[connfd].r_action.recv_callback(connfd); if (events[i].events \u0026amp; EPOLLOUT) conn_list[connfd].send_callback(connfd); } } timeout == -1 表示没有事件时一直阻塞，避免空转消耗 CPU。\n两个独立的 if 还是 if ... else if？ 两个独立 if 理论上允许同一次返回同时处理读、写事件；else if 每轮只处理其中一种。\n但不能简单认为两个 if 一定更好。若读回调发现连接已经断开并关闭了 fd，随后继续执行写回调就可能访问已经失效或被复用的 fd。稳妥做法是：\n先处理 EPOLLERR、EPOLLHUP、EPOLLRDHUP。 回调返回关闭状态后，不再处理该 fd 的其他事件。 再依据连接状态处理读写事件。 8. 课堂代码补充 这份代码主要是为了演示 Reactor 的整体流程，所以有些细节课堂上只是带了一下。自己再写一遍时，可以顺手补上下面这些地方。\n关于 send()\n课上代码里有一句：\nint count = send(fd, conn_list[fd].wbuffer, count, 0); 这里最后的 count 还没有赋值，应该写成 conn_list[fd].wlength。\n另外，一次 send() 不一定能把 wlength 个字节全部发完。可以在连接对象里再记一个 woffset，每次发送后往后移动。数据还没发完就继续等 EPOLLOUT，全部发完后再切回 EPOLLIN。\n关于 recv()\n记住三个返回值：\n大于 0：本次收到的字节数； 等于 0：对端关闭连接； 小于 0：要结合 errno 判断，非阻塞情况下可能只是暂时没有数据。 不能让 -1 继续参与后面的 memcpy()，否则长度转换后可能越界。EINTR 可以重试，EAGAIN/EWOULDBLOCK 表示这次先读到这里，其他情况再按真正的错误处理。\n还有一点：recv() 收到的是一段字节，不会自动在末尾补 \\0。要用 %s 打印时需要自己留位置并补结束符；如果处理二进制数据，就一直按长度来处理。\n关于非阻塞\nReactor 一般要和非阻塞 socket 配合。监听 fd 和客户端 fd 都可以设置成 O_NONBLOCK，避免某一次 accept()、recv() 或 send() 卡住整个事件循环。\nfcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); 如果以后从 LT 改成 ET，accept() 和 recv() 通常都要放在循环里，一直处理到返回 EAGAIN/EWOULDBLOCK。当前是 LT，一次只 accept() 一个连接也能继续触发，不过连接集中到来时，循环取完会更快一些。\n关于 fd 和连接状态\n当前写法直接用 fd 作为 conn_list 的下标，使用前要判断：\nif (fd \u0026lt; 0 || fd \u0026gt;= CONNECTION_SIZE) return -1; 连接关闭时也不只是 close(fd)，对应的读写长度、偏移量和回调最好一起清空。因为 fd 的数字会被复用，下一个新连接有可能再次拿到同一个编号。\nfd 也不能当作累计连接数。想每建立 1000 个连接打印一次，应该另外维护一个 accepted_count，不能用 clientfd % 1000 来判断。\n其他随手记下的点\nsocket()、bind()、listen()、epoll_ctl()、epoll_wait() 等系统调用都要看返回值； 监听 socket 通常会设置 SO_REUSEADDR； listen(sockfd, 10) 中的 10 是 backlog，不是服务器最多只能保持 10 条连接； 主循环除了 EPOLLIN、EPOLLOUT，还要留意 EPOLLERR、EPOLLHUP 和 EPOLLRDHUP； 对端关闭后继续发送可能触发 SIGPIPE，可以忽略该信号，或在 send() 时使用 MSG_NOSIGNAL； TIME_SUB_MS(current, begin) 当前记录的是两次打印之间的间隔，不是从程序启动开始的总耗时。 9. 大包、拆包与粘包 代码中的读缓冲区只有 1024 字节。若客户端发送 2048 字节，一次 recv() 可能只收到其中一部分；即使缓冲区足够大，TCP 也不保证一次接收对应一次发送。\n因此不能把：\n一次 send = 一个业务消息 = 一次 recv 当成可靠规律。\n业务协议需要定义消息边界，常见方法有：\n固定长度消息。 特殊分隔符，例如文本协议中的换行。 消息头携带正文长度，例如“4 字节长度 + 消息体”。 HTTP 中使用请求行、首部、Content-Length 或分块传输规则。 Reactor 收到数据后应先追加到该连接自己的缓冲区，再交给协议解析器：\nrecv → 追加到连接缓冲区 → 判断是否形成完整消息 → 未完整：保留并等待下次 EPOLLIN → 已完整：取出消息并处理，剩余数据继续保留 如果固定数组空间不足，可以使用动态缓冲区、环形缓冲区或链式缓冲区。\n10. 为什么会出现 Too many open files 报错对应 errno == EMFILE（常见值为 24），表示当前进程已经达到可打开 fd 的上限。\n一条 TCP 连接在客户端和服务端各自都占用一个 fd。百万连接测试至少要同时考虑：\n10.1 进程级限制 查看当前 shell 的限制：\nulimit -n ulimit -a 临时提高当前 shell 的 soft limit：\nulimit -n 1048576 程序必须从修改后的 shell 中启动，才能继承该限制。还要确保 hard limit 足够高。\n10.2 系统级限制 cat /proc/sys/fs/file-max cat /proc/sys/fs/file-nr fs.file-max 是系统范围的文件句柄限制，ulimit -n 是单进程/会话限制，两者不是同一个概念。持久化设置还取决于系统的 PAM、systemd unit 和发行版配置，不能只改一个参数就假定生效。\n10.3 程序自身限制 CONNECTION_SIZE 是否覆盖可能出现的 fd 值； 进程虚拟内存和实际物理内存是否足够； 每连接缓冲区是否过大； 是否存在 fd 泄漏； epoll 实例和监听 socket 自己也会占 fd。 11. 为什么单个目标端口大约只能建立数万连接 一条 TCP 连接由五元组唯一标识：\n源 IP、源端口、目的 IP、目的端口、传输层协议 在单台压测客户端连接单台服务器的单个端口时：\n源 IP 固定； 目的 IP 固定； 目的端口固定； 协议固定为 TCP； 主要只能变化源端口。 当可用临时源端口耗尽时，客户端可能报：\nCannot assign requested address 11.1 一个需要校正的细节 客户端临时端口范围并非固定为 1024~65535。Linux 实际自动分配范围应通过下面的命令查看：\nsysctl net.ipv4.ip_local_port_range 不同系统、不同配置的范围可能不同，部分端口还可能被占用、保留，或暂时处于 TIME_WAIT 等状态。因此可建立数量通常小于理论上的 65535。\n11.2 为什么增加到 20 个服务端端口有效 将目标端口从一个增加到 2000~2019，改变了五元组中的“目的端口”。只要客户端把连接分散到这些端口，同一个源端口就可以与不同目的端口组成不同连接，理论连接空间随之扩大。\n其他扩展方式还包括：\n使用多个客户端源 IP； 使用多台压测机； 使用多个服务端 IP； 使用多个网络命名空间，但必须正确配置各自地址和路由。 不能仅用单台客户端的结果直接代表服务端的最大承载能力，因为压测端本身可能先耗尽端口、fd、CPU、内存或 conntrack 容量。\n12. nf_conntrack 应该怎样理解 nf_conntrack 是 Linux Netfilter 的连接跟踪机制，防火墙、NAT 等功能会用到它。它不是 epoll 工作所必需的组成部分。\n如果测试路径启用了连接跟踪，并且连接数达到表容量，才需要观察：\nsysctl net.netfilter.nf_conntrack_max cat /proc/sys/net/netfilter/nf_conntrack_count 旧资料中的 ip_conntrack 是较早的名称；现代内核通常使用 nf_conntrack。只有确实需要且模块尚未加载时，才考虑：\nsudo modprobe nf_conntrack 不应看到连接问题就盲目加载模块或增大参数。应先确认机器是否启用了防火墙/NAT、模块是否存在、表是否真的已满。在服务端还是客户端调整，取决于哪台机器实际承担连接跟踪；中间的 NAT/防火墙设备也可能成为瓶颈。\n13. 百万连接断开时为什么 CPU 会突然升高 如果一次性终止百万连接的一端，另一端会在短时间内集中处理大量 TCP 状态变化与关闭事件，例如 FIN/RST、epoll 唤醒、连接对象清理、定时器更新等。\n这会形成“惊群式的业务处理洪峰”或资源回收洪峰，表现为 CPU 短时暴增。它不一定表示程序死循环，而可能是系统在集中处理积压事件。\n优化方向包括：\n分批建立和分批关闭连接； 减少关闭路径上的日志和同步操作； 将耗时清理异步化； 使用高效定时器结构，如时间轮； 避免每连接大量内存分配和释放； 做好背压和过载保护； 使用多 Reactor、多线程或多进程分摊事件处理； 用火焰图、perf、系统指标确认 CPU 真正消耗在哪里。 优化目标不是保证 CPU 永不升高，而是让峰值可控、持续时间缩短，并保证服务仍能继续响应。\n14. “百万并发”不等于“百万 QPS” 性能测试至少要区分四个维度：\n指标 含义 并发连接数 同一时刻保持的连接数量 QPS/吞吐量 单位时间完成的请求或消息数量 延迟 一次请求从发出到收到响应所需时间，通常关注 P50/P95/P99 测试用例 报文大小、收发频率、连接是否长连、业务是否计算或访问存储 保持 100 万条空闲 TCP 连接，和让 100 万条连接持续高速收发数据，是完全不同的负载。前者更考验 fd、内存和内核连接管理；后者还会迅速消耗 CPU、网络带宽、内存带宽以及业务依赖资源。\n因此，一份可信的测试报告至少应说明：\n客户端和服务端硬件配置； 内核和发行版版本； 所有相关内核参数与 fd 限制； 连接建立速率； 活跃连接与空闲连接比例； 请求/响应大小和频率； QPS、吞吐量、错误率和延迟分位数； CPU、内存、网络、fd、conntrack 使用率； 测试持续时间和连接关闭方式。 15. 本节课一句话总结 Reactor 的本质不是“用了 epoll 就很快”，而是把每个 fd 的状态独立保存起来，由事件循环将 I/O + event 精确分发给相应 callback；百万连接则是在这个正确模型之上，再共同解决 fd、内存、端口空间、内核网络栈和压测方法等系统性限制。\n","date":"2026-08-08T00:00:00+08:00","image":"/MyBlog/p/reactor-epoll-million-connections/cover.svg","permalink":"/MyBlog/p/reactor-epoll-million-connections/","title":"Reactor 与 epoll：百万并发网络服务器"},{"content":"1. 为什么需要 IO 多路复用 网络服务器主要管理两类 socket：\n监听 socket（listenfd / 代码中的 sockfd） 它出现可读事件，通常表示已经完成 TCP 握手的连接正在等待 accept()。 已连接 socket（clientfd / connfd） 它出现可读事件，可能表示收到了数据，也可能表示对端关闭连接；随后由 recv() 判断具体情况。它也可能出现可写、错误、挂断等事件。 最直接的服务器模型是“一连接一线程”：主线程执行 accept()，每建立一个连接便创建一个线程，由该线程阻塞在 recv() 上。\nint clientfd = accept(sockfd, ...); pthread_create(\u0026amp;thid, NULL, client_thread, \u0026amp;clientfd); 这种模型的优点是代码直观，一个线程可以按顺序处理一个连接的收、发逻辑。缺点是连接数增加时，需要大量线程；线程会占用栈空间和内核资源，还会产生调度及上下文切换成本。许多长连接大部分时间并没有数据，线程却仍然存在，因此不适合大量空闲连接并发存在的场景。\nIO 多路复用的核心思想：让一个线程同时等待多个 fd。线程不再阻塞于某一个 recv()，而是阻塞在 select()、poll() 或 epoll_wait() 上；哪些 fd 已经就绪，线程就处理哪些 fd。\n“多路”指多个 IO/fd，“复用”指复用同一个线程进行等待和处理。它复用的是等待能力，并不会自动并行执行业务代码。\n2. “IO 就绪”与“完成 IO” select、poll、epoll 提供的是就绪通知机制：\n监听 fd 可读：通常可以调用 accept()。 客户端 fd 可读：可以调用 recv()；也可能是对端关闭或发生错误。 客户端 fd 可写：内核发送缓冲区当前有空间，并不等于全部数据已经发送完成。 它们只告诉应用程序“现在适合尝试某项 IO 操作”，不会替应用程序完成请求解析、业务计算和响应组装。\n一个长连接的生命周期会经历许多次事件，而不是“连接一次只触发一个事件”：\n建立连接 → 收到请求 → 发送响应 → 再次收到请求 → …… → 断开连接 因此，用户在线数或 TCP 连接数并不等于同一时刻的活跃请求数。例如服务器可能维护 100 万个连接，但某一时刻只有 1 万个连接真正产生事件。高并发服务器真正希望快速取得并处理的是这 1 万个就绪 fd。\n3. select 3.1 接口 int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); 五类参数的含义：\nnfds：三个集合中最大 fd 值加 1，而不是 fd 的数量。 readfds：关注可读事件的 fd 集合。 writefds：关注可写事件的 fd 集合。 exceptfds：关注异常条件的 fd 集合；它并不是所有网络错误的统一集合。 timeout：最长等待时间。 NULL：一直阻塞，直到出现事件或被信号中断。 {0, 0}：立即返回，成为轮询。 指定时间：超时后返回 0，可让事件循环顺便检查定时任务，但精确定时器通常还需要专门的数据结构。 返回值：\n\u0026gt; 0：返回集合中就绪项的数量。 = 0：超时。 = -1：调用失败，例如被信号中断时 errno == EINTR。 3.2 如何理解 fd_set fd_set 可以理解成一个位图集合：fd 是非负整数，位图中编号为 fd 的那一位表示该 fd 是否属于集合。\nfd 编号： 0 1 2 3 4 5 6 7 8 位图值： 0 0 0 1 1 1 1 0 0 ↑ 关注 3、4、5、6 常用宏：\nFD_ZERO(\u0026amp;set); // 清空集合 FD_SET(fd, \u0026amp;set); // 加入 fd FD_CLR(fd, \u0026amp;set); // 移除 fd FD_ISSET(fd, \u0026amp;set); // 判断 fd 是否位于集合中 在典型 Linux/glibc 环境中，FD_SETSIZE 为 1024，fd_set 可表示编号 0～1023 的 fd，通常占 1024 bit，即 128 byte。但应以当前平台的 FD_SETSIZE 和 sizeof(fd_set) 为准。\n需要注意：在 Linux 上，仅仅尝试修改 FD_SETSIZE 并不能可靠地突破 select() 的 1024 fd 限制；处理大量 fd 应改用 poll 或 epoll。此外，“最多 1024 个 fd”并不完全等于“最多 1024 个客户端”，因为进程还会占用标准输入输出、监听 socket、文件等其他 fd，而且 fd 编号必须小于 FD_SETSIZE。\n3.3 为什么代码需要 rfds 和 rset 两份集合 附件代码中：\nfd_set rfds, rset; FD_ZERO(\u0026amp;rfds); FD_SET(sockfd, \u0026amp;rfds); while (1) { rset = rfds; int nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); ... } 原因是 select() 的 fd 集合是传入兼传出参数：\n调用前，集合表示“应用程序关注哪些 fd”。 返回后，集合被改写，只保留本次已经就绪的 fd。 因此：\nrfds 保存长期关注的完整集合（整集）。 rset 是每次调用时复制出的工作集合，返回后用于判断本轮哪些 fd 就绪。 代码先判断监听 fd：\nif (FD_ISSET(sockfd, \u0026amp;rset)) { int clientfd = accept(sockfd, ...); FD_SET(clientfd, \u0026amp;rfds); } 然后从较小 fd 一直扫描到 maxfd，找出就绪客户端并执行 recv()：\nfor (int i = sockfd + 1; i \u0026lt;= maxfd; i++) { if (FD_ISSET(i, \u0026amp;rset)) { recv(i, ...); } } 3.4 优缺点 优点：\n一个线程可以等待多个 fd。 跨 Unix 类系统的可移植性较好。 fd 数量较少时足够简单实用。 缺点：\n每次调用都要把集合从用户态复制到内核态，返回时还要复制回来。 内核需要检查关注集合；应用层也要扫描到 maxfd 才知道谁就绪。 集合会被改写，调用前必须重新准备。 Linux 上受 FD_SETSIZE 限制。 参数和集合管理较烦琐。 4. poll 4.1 数据结构与接口 poll 使用结构体数组描述关注对象：\nstruct pollfd { int fd; // 被监控的 fd short events; // 输入：关注的事件 short revents; // 输出：实际发生的事件 }; int poll(struct pollfd *fds, nfds_t nfds, int timeout); 常见事件包括：\nPOLLIN：可读。 POLLOUT：可写。 POLLERR：发生错误。 POLLHUP：挂断。 POLLNVAL：fd 无效。 timeout 的单位是毫秒：\n-1：一直阻塞。 0：立即返回。 \u0026gt; 0：最多等待指定毫秒数。 与 select 相比，pollfd 把“关注的事件”和“实际发生的事件”分开，因此不必在每轮调用前重建整个关注集合。它也没有 FD_SETSIZE 这种固定位图上限；实际容量仍受进程 fd 限额和内存限制。\n4.2 poll 是否从根本上解决了性能问题 没有。每次调用 poll() 时，仍需把 pollfd 数组交给内核；内核和应用层仍需要在数组中检查事件。因此大量 fd 中只有少量就绪时，工作量仍与整个关注集合的规模相关。\npoll 的主要价值是接口更统一、没有 select 的固定 fd 编号限制，而不是在大量连接场景中获得 epoll 那种只返回就绪项的机制。\n适合场景通常包括：\n需要比 select 更方便的事件表达方式。 fd 数量不特别大。 需要较好的 Unix 平台可移植性，而不能使用 Linux 专有的 epoll。 4.3 附件 poll 代码中的两个重要问题 原代码连续调用了两次 poll()：\npoll(fds, maxfd + 1, -1); int nready = poll(fds, maxfd + 1, -1); 第一次调用已经取得事件，第二次又重新阻塞；应删除第一次，只保留：\nint nready = poll(fds, maxfd + 1, -1); 此外，代码用 fd 值作为数组下标，但数组被 {0} 初始化。未使用位置的 fd 因而是 0，会意外重复监控标准输入。poll 约定 fd \u0026lt; 0 的项会被忽略，因此所有空槽应初始化为 -1。更常见的写法是紧凑地保存有效 pollfd 项，而不是把 fd 直接当数组下标。\n5. epoll epoll 是 Linux 专用的 IO 事件通知机制。它将“维护关注集合”和“等待就绪事件”拆开，尤其适合大量连接、少量活跃的服务器场景。\n5.1 三个核心接口 1. 创建 epoll 实例 int epfd = epoll_create(1); 返回的 epfd 本身也是一个 fd，代表一个内核中的 epoll 实例。较新的代码通常使用：\nint epfd = epoll_create1(EPOLL_CLOEXEC); epoll_create(size) 的 size 在现代 Linux 中已被忽略，但必须大于 0；保留它是为了兼容旧接口。它不是“单次最多返回多少个就绪事件”。单次最多返回多少项由 epoll_wait() 的 maxevents 决定。\n2. 增删改关注对象 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 三种操作：\nEPOLL_CTL_ADD：加入一个 fd。 EPOLL_CTL_DEL：删除一个 fd。 EPOLL_CTL_MOD：修改该 fd 关注的事件或关联数据。 事件结构体：\nstruct epoll_event { uint32_t events; // EPOLLIN、EPOLLOUT 等 epoll_data_t data; // 可保存 fd、指针等用户数据 }; 附件先注册监听 fd：\nev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); accept() 得到新客户端后，再单独将 clientfd 加入关注集合。\n3. 等待就绪事件 int nready = epoll_wait(epfd, events, maxevents, timeout); events：用户提供的结果数组，用来接收本次就绪项。 maxevents：结果数组最多能装多少项，必须大于 0；它不是 epoll 能管理的 fd 总数。 timeout：-1 一直等待，0 立即返回，正数表示最多等待的毫秒数。 返回值：本轮写入 events 数组的就绪项数，超时返回 0，失败返回 -1。 应用只需遍历 events[0..nready-1]：\nfor (int i = 0; i \u0026lt; nready; i++) { int fd = events[i].data.fd; ... } 5.2 “整集”和“就绪集” 从概念上看，epoll 实例在内核中维护两个重要集合：\n关注集合（interest list）：应用通过 epoll_ctl() 增、删、改的所有 fd。 就绪集合（ready list）：其中当前有事件需要应用处理的 fd。 传统实现说明中常把关注集合描述为红黑树、就绪集合描述为链表。但这些属于 Linux 内核实现细节，理解接口时更重要的是“长期关注集合”和“当前就绪集合”的分离，不应让业务代码依赖具体内部数据结构。\n5.3 为什么 epoll 更适合大并发 以 100 万个连接、当前 1 万个连接活跃为例：\nselect/poll：每轮等待都需要提交并检查规模接近 100 万的关注集合，应用还要从大集合里寻找就绪项。 epoll：连接建立或状态变化时，通过 epoll_ctl() 增量维护关注集合；epoll_wait() 主要把当前就绪项返回给应用，应用遍历约 1 万个结果。 因此，epoll 的优势不应简单概括成“完全没有遍历或拷贝”，而应表述为：\n不必在每次等待时重新提交整个关注集合。 应用遍历的是本轮返回的就绪项，而不是从 0 扫描至最大 fd。 连接管理是增量进行的，适合大量长连接中只有少量活跃的情形。 不过，epoll 不会消除业务处理、数据复制、系统调用等全部成本；当绝大部分连接始终活跃时，它相对 poll 的优势也会缩小。\n5.4 LT 与 ET epoll 有两种常见触发方式：\nLT（Level Triggered，水平触发）：默认方式。只要 fd 仍处于就绪状态，后续 epoll_wait() 还会继续报告，较容易编写。 ET（Edge Triggered，边沿触发）：只有状态发生变化时重点通知。通常必须配合非阻塞 fd，并持续 accept() / recv() / send()，直到返回 -1 且 errno 为 EAGAIN 或 EWOULDBLOCK，否则可能遗漏尚未处理完的数据。 附件只设置了 EPOLLIN，没有设置 EPOLLET，因此使用的是 LT。\n6. 从 IO 多路复用到 Reactor select、poll、epoll 解决的是：\n哪些 fd 上发生了应用关心的 IO 事件？\n但实际服务器还需要解决：\n某类事件发生以后，应当执行哪段业务逻辑？\nReactor 是一种事件驱动的程序组织模式。应用先把“事件”和“处理函数”注册起来；事件循环等待事件，事件发生后再把它分派给对应处理函数。\n注册 fd + 关注事件 + 回调函数 ↓ select / poll / epoll 等待 ↓ 得到就绪事件 ↓ Reactor 分派事件 ↓ accept_cb / recv_cb / send_cb 例如：\nlistenfd + READ -\u0026gt; accept_cb() clientfd + READ -\u0026gt; recv_cb() clientfd + WRITE -\u0026gt; send_cb() 因此，可以把两者的关系概括为：\nselect/poll/epoll 是底层的事件检测或事件分离机制。 Reactor 是上层的事件注册、循环等待和回调分派模式。 Reactor 关注的中心从“对某个 fd 写一段固定流程”转为“某个 fd 的某种事件应该调用哪个 handler”。 它和路由有相似之处：路由根据请求路径找到处理器，Reactor 根据 fd 上发生的事件找到处理器。但 Reactor 还负责事件监听、生命周期管理和持续循环，因此不能简单等同于路由。\n典型 Reactor 由以下部分组成：\n事件源：监听 socket、客户端 socket、定时器等。 事件多路分离器：select、poll 或 epoll。 Reactor / Dispatcher：事件循环与分派器。 Handler / Callback：处理连接、读取、写入、关闭等事件的函数。 Reactor 只是组织 IO 事件处理的模式，并不意味着业务一定只使用一个线程。常见结构包括：\n单 Reactor、单线程。 单 Reactor + 工作线程池。 主 Reactor 负责接收连接，多个从 Reactor 负责已连接 socket，再配合工作线程池。 耗时业务不能长期阻塞事件循环，否则一个回调会拖延其他连接；CPU 密集型或阻塞型任务通常交给工作线程池。\n7. 三种工具对比 特性 select poll epoll 平台 多种 Unix 类系统 多种 Unix 类系统 Linux 关注集合表示 fd_set 位图 pollfd 数组 内核维护的 epoll 关注集合 每轮是否重新提交整个关注集合 是 是 否，使用 epoll_ctl 增量维护 应用如何找就绪项 扫描到 maxfd 扫描数组 遍历 epoll_wait 返回的就绪数组 固定 1024 fd 编号限制 Linux 上通常有 无此固定限制 无此固定限制 主要适用情况 fd 较少、重视可移植性 fd 中等、重视可移植性和统一事件接口 Linux 大量连接、少量活跃 不能笼统地说谁在所有场景下一定最快。fd 很少时差别可能很小；平台兼容性、接口复杂度、连接活跃比例和业务负载都应纳入选择。\n8. 附件代码的演进关系 代码使用条件编译展示了四个阶段：\n单连接阻塞处理 ↓ 循环 accept，但一个连接的 recv 会阻塞后续连接 ↓ 一连接一线程，逻辑简单但线程成本随连接数增长 ↓ select / poll / epoll，一个线程等待多个 fd ↓ 进一步封装事件、状态与回调，即 Reactor 当前真正编译的是最后的 #else，即 epoll 版本；前面的分支因为使用 #if 0 / #elif 0 而不会参与编译。\n9. 先记住三套代码共同的骨架 学习这三个接口时，不要一开始背所有参数。先固定服务器永远重复的四步：\n1. 注册：告诉系统要关注哪些 fd、哪些事件 2. 等待：阻塞到至少一个事件发生 3. 分发：判断是监听 fd，还是客户端 fd 4. 更新：新连接加入，断开的连接删除，需要时修改关注事件 把三种接口代入同一骨架：\n阶段 select poll epoll 保存关注对象 fd_set rfds struct pollfd fds[] 内核中的 epoll 关注集合 注册新客户端 FD_SET(clientfd, \u0026amp;rfds) 添加一个 pollfd epoll_ctl(...ADD...) 等待事件 select(...) poll(...) epoll_wait(...) 找出就绪对象 扫描并调用 FD_ISSET 扫描并检查 revents 直接遍历返回的 events 删除客户端 FD_CLR(fd, \u0026amp;rfds) 删除数组项或设为 -1 epoll_ctl(...DEL...) 下面三段代码都实现同一个核心功能：\n监听 fd 可读 → accept 新连接 → 注册 clientfd clientfd 可读 → recv 数据 → send 回显 连接关闭/出错 → 删除 clientfd → close 为了突出多路复用主干，示例省略了非阻塞设置、完整发送缓冲区、信号处理等生产级细节。\n10. select 核心代码逐段对照 10.1 初始化：建立长期关注集合 fd_set rfds, rset; // 完整关注集合、本轮就绪集合 FD_ZERO(\u0026amp;rfds); // 初始为空 FD_SET(sockfd, \u0026amp;rfds); // 首先关注监听 fd int maxfd = sockfd; // select 要知道最大的 fd 此时可以想象：\nrfds = { sockfd } 10.2 每轮复制并等待 while (1) { rset = rfds; int nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); 这里两个集合的职责一定要分清：\n调用前：rfds = 长期关注的完整集合 rset = 复制品，也包含完整集合 返回后：rfds = 保持不变，供下一轮继续使用 rset = 被 select 改写，只保留本轮就绪 fd maxfd + 1 中的 +1 是因为 nfds 表示检查区间的右边界：内核检查 fd 0 到 maxfd，即 [0, maxfd + 1)。\n10.3 监听 fd 就绪：接收并注册新连接 if (FD_ISSET(sockfd, \u0026amp;rset)) { // sockfd 可读不是有业务数据，而是有连接可以取出 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // accept 不会删除 sockfd；它返回一个新的 clientfd FD_SET(clientfd, \u0026amp;rfds); if (clientfd \u0026gt; maxfd) maxfd = clientfd; } 要点：\n检查的是返回集合 rset，因为要问“本轮谁就绪”。 新连接加入的是长期集合 rfds，因为要从下一轮开始持续关注它。 accept() 从已完成连接队列取出一个连接，但 sockfd 仍然继续监听。 新 clientfd 本轮不在 rset 中，从下一轮开始接受监控。 10.4 扫描客户端：处理并删除 for (int fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (!FD_ISSET(fd, \u0026amp;rset)) continue; // 这个 fd 本轮不可读 char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); // Echo：原样回发 } else { // count == 0 表示对端关闭；教学代码也在出错时删除 FD_CLR(fd, \u0026amp;rfds); close(fd); } } } 这里的“可读”应理解为：现在调用读取类操作能够立即得到结果，而不会因为没有事件一直阻塞：\n监听 fd 可读 → 调用 accept()，取出一个连接 客户端 fd 可读 → 调用 recv()，读取数据或得知对端关闭 maxfd = sockfd 也不是说监听 fd 永远最大，而是初始化时集合中只有 sockfd。加入新客户端以后，再通过比较更新 maxfd。\nselect 版最值得背下来的代码主干是：\nrset = rfds; nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); if (FD_ISSET(sockfd, \u0026amp;rset)) { clientfd = accept(...); FD_SET(clientfd, \u0026amp;rfds); } for (fd = sockfd + 1; fd \u0026lt;= maxfd; fd++) { if (FD_ISSET(fd, \u0026amp;rset)) recv(fd, ...); } 记忆关键词：复制集合、maxfd + 1、扫描、FD_ISSET。\n11. poll：对照附件代码理解 你的写法直接把 fd 当作 fds 数组下标：fd 3 放在 fds[3]，fd 4 放在 fds[4]。下面保留这种写法，方便和原代码逐行对应。\n11.1 初始化数组 #define MAX_FDS 1024 struct pollfd fds[MAX_FDS]; // poll 会忽略 fd \u0026lt; 0 的项 for (int i = 0; i \u0026lt; MAX_FDS; ++i) { fds[i].fd = -1; fds[i].events = 0; fds[i].revents = 0; } fds[sockfd].fd = sockfd; fds[sockfd].events = POLLIN; int maxfd = sockfd; 此时：\n假设 sockfd = 3： fds[3].fd = 3 fds[3].events = POLLIN events = POLLIN 表示“我希望观察可读”，revents 表示 poll() 返回时“本轮实际发生了什么”。\n11.2 等待事件 while (1) { int nready = poll(fds, maxfd + 1, -1); 调用前后：\nevents = 输入：希望关注什么 revents = 输出：实际上发生了什么 与 select 不同，events 不会被替换成就绪集合，因此下一轮通常不必重建所有关注事件；但应把 revents 当作当轮结果使用。\n附件原本连续调用了两次 poll()，应删掉第一次，否则第一次取得事件后，第二次又会重新阻塞。\n11.3 监听 fd 就绪：注册新客户端 if (fds[sockfd].revents \u0026amp; POLLIN) { // 监听 fd 可读：取出一个新连接 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // clientfd 直接作为数组下标 fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; if (clientfd \u0026gt; maxfd) maxfd = clientfd; } 例如 sockfd = 3、accept() 返回 clientfd = 4：\nfds[3] 管理监听 fd 3：可读时调用 accept() fds[4] 管理客户端 fd 4：可读时调用 recv() 11.4 扫描客户端的 revents for (int fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (!(fds[fd].revents \u0026amp; POLLIN)) continue; // fd 本轮没有发生可读事件 char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); } else { close(fd); fds[fd].fd = -1; // 让 poll 忽略这个位置 fds[fd].events = 0; } } } poll 版最值得背下来的代码主干是：\nfds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; nready = poll(fds, maxfd + 1, -1); for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) { if (fds[fd].revents \u0026amp; POLLIN) recv(fd, ...); } 记忆关键词：events 是我想观察什么，revents 是本轮实际发生了什么。\n12. epoll 核心代码逐段对照 epoll 的明显区别是：应用不再把完整关注数组传给每一次等待，而是提前通过 epoll_ctl() 增量维护关注集合。\n12.1 创建实例并注册监听 fd int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); 此处可以理解为：\nepoll_create：创建一个事件管理器 epoll_ctl ADD：向管理器登记“关注 sockfd 的可读事件” 其中 ev.data.fd = sockfd 可以看成给这次登记贴上“sockfd 身份标签”。内核会保存这个标签，等该 fd 就绪时再原样放进返回结果中。\n12.2 等待并直接遍历就绪数组 while (1) { struct epoll_event events[1024] = {0}; int nready = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i \u0026lt; nready; ++i) { int fd = events[i].data.fd; 这里两个变量要严格分开：\nev ：调用 epoll_ctl() 时送进去的登记卡 events[] ：调用 epoll_wait() 时拿回来的结果数组 例如注册时写入：\nev.data.fd = 3; epoll_ctl(epfd, EPOLL_CTL_ADD, 3, \u0026amp;ev); 当 fd 3 可读时，epoll_wait() 可能返回：\nnready = 1 events[0].data.fd = 3 events[0].events = EPOLLIN 所以 events[i].data.fd 不是凭空出现的，它就是注册时 ev.data.fd 中保存的身份标签。\n12.3 监听 fd 就绪：ADD 新客户端 if (fd == sockfd) { // 监听 fd 可读：从连接队列中取出一个连接 int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 给新客户端准备新的登记信息 ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); continue; } accept() 不会删除监听 fd。假设 sockfd = 3、它返回 clientfd = 5，注册完成后可以这样理解：\nfd 3 → 可读时返回标签 3 → 调用 accept() fd 5 → 可读时返回标签 5 → 调用 recv() 12.4 客户端就绪：读取或 DEL if (events[i].events \u0026amp; EPOLLIN) { char buffer[1024] = {0}; int count = recv(fd, buffer, sizeof(buffer), 0); if (count \u0026gt; 0) { send(fd, buffer, count, 0); } else { // 客户端断开：先从 epoll 删除，再关闭 fd epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } epoll 版最值得背下来的代码主干是：\nepfd = epoll_create(1); ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, \u0026amp;ev); nready = epoll_wait(epfd, events, 1024, -1); for (i = 0; i \u0026lt; nready; i++) { fd = events[i].data.fd; if (fd == sockfd) { clientfd = accept(...); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); } else if (events[i].events \u0026amp; EPOLLIN) { recv(fd, ...); } } 记忆关键词：create 创建、ctl 管理、wait 等待、只遍历就绪项。\n13. 把三段代码放在一起看 13.1 注册客户端 // select：修改用户态位图 FD_SET(clientfd, \u0026amp;rfds); // poll：修改用户态结构体数组 fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; // epoll：通过系统调用修改内核关注集合 ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, \u0026amp;ev); 13.2 等待事件 // select：集合会被改写，所以先复制 rset = rfds; nready = select(maxfd + 1, \u0026amp;rset, NULL, NULL, NULL); // poll：传入完整 pollfd 数组 nready = poll(fds, maxfd + 1, -1); // epoll：关注集合已在内核，只传结果数组 nready = epoll_wait(epfd, events, 1024, -1); 13.3 寻找就绪客户端 // select：监听 fd 已单独处理，这里遍历客户端 fd for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) if (FD_ISSET(fd, \u0026amp;rset)) { ... } // poll：遍历完整 pollfd 数组 for (fd = sockfd + 1; fd \u0026lt;= maxfd; ++fd) if (fds[fd].revents \u0026amp; POLLIN) { ... } // epoll：遍历本轮就绪数组 for (i = 0; i \u0026lt; nready; ++i) if (events[i].events \u0026amp; EPOLLIN) { ... } 13.4 删除客户端 // select FD_CLR(fd, \u0026amp;rfds); close(fd); // poll close(fd); fds[fd].fd = -1; fds[fd].events = 0; // epoll epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); 最核心的差别就体现在“完整集合放在哪里、每轮遍历谁”：\nselect：应用保存位图 → 每轮复制 → 扫描 0..maxfd poll： 应用保存数组 → 每轮提交 → 扫描完整数组 epoll： 内核保存关注集合 → 每轮只取出并遍历就绪数组 总结 一连接一线程用线程等待每个连接；IO 多路复用让一个线程等待多个 fd；select 和 poll 每轮仍需处理整个关注集合，而 epoll 将关注集合长期维护在内核中并返回当前就绪项；Reactor再进一步把这些就绪事件分派给预先注册的回调函数，使服务器从“管理 fd”上升为“管理事件及其处理逻辑”。\n","date":"2026-08-07T00:00:00+08:00","image":"/MyBlog/p/io-multiplexing-and-reactor/cover.svg","permalink":"/MyBlog/p/io-multiplexing-and-reactor/","title":"IO 多路复用与 Reactor"},{"content":"一、网络 I/O 的使用场景 网络是后端服务开发中的重要环节。生活中大量功能都依赖客户端与服务端之间的网络通信。\n1. 微信发送文字、语音和视频 当用户在微信发送消息时，大致过程是：\n发送方微信 ↓ 通过网络将数据发送给微信服务器 ↓ 服务器找到接收方 ↓ 将数据转发给接收方微信 文字、语音、图片、视频在网络上传输时，最终都会被转换成字节数据。\n网络 I/O 主要关心：\n数据从哪里读取 数据向哪里发送 读取了多少字节 发送了多少字节 网络 I/O 本身并不关心这些字节具体表示文字、图片还是视频。\n2. 刷抖音 刷视频时：\n抖音 App 请求视频 ↓ 服务器返回视频信息和资源地址 ↓ App 通过网络下载视频数据 ↓ 客户端解码并播放 通常视频会被切分成多个数据块，App 可以一边下载、一边播放。\n3. 使用 git clone 执行：\ngit clone 仓库地址 大致过程是：\n本地 Git 客户端连接 GitHub ↓ 向 GitHub 请求仓库数据 ↓ GitHub 通过网络发送 Git 对象 ↓ 本地接收、校验和解压 ↓ 形成本地仓库 代码能够到达本地，本质上也是客户端和 GitHub 服务端之间进行了网络 I/O。\n这些场景都可以概括成：\n客户端发起请求 → 服务端处理请求 → 服务端返回响应 二、什么是网络 I/O 网络 I/O 是建立在客户端和服务端之间的数据传输过程，类似二者之间连接的网络管道。例如十个客户端分别连接服务器，服务端通常会获得十个连接 fd：\n客户端 A ←→ fd 4 客户端 B ←→ fd 5 客户端 C ←→ fd 6 ... 客户端 J ←→ fd 13 随着客户端数量增加，fd 数量也会增加，于是就产生了一个重要问题：\n服务端如何同时管理大量 fd？\n这正是后面需要学习 select、poll 和 epoll 的原因。\n三、socket 与 fd 在 Linux 中，Linux 通过 socket API 通信。创建 socket：\nint sockfd = socket(AF_INET, SOCK_STREAM, 0); 参数的含义：\nAF_INET：IPv4 SOCK_STREAM：面向字节流，对应 TCP 0：使用该组合默认的 TCP 协议 fd 本质是进程中的非负整数（此处了解，记忆也没啥用）：\n0：标准输入 1：标准输出 2：标准错误 3：监听 socket 4：客户端连接 A 5：客户端连接 B 在当前服务端进程中，一个已连接 socket 的 fd 通常对应一条 TCP 连接。（存在 ACCEPT 的情况）\n四、TCP 服务端的简便理解 可以把 TCP 服务端理解成酒店：\nsocket()：聘请迎宾员 bind()：安排迎宾员在哪个门口工作 listen()：正式开始迎宾 accept()：接待一位到达的客人 recv()：听客人提出需求 send()：将处理结果交给客人 close()：结束服务 说说两类重要 fd：\n1. 监听 fd\n定义完 sockfd 经过 bind、listen 后，sockfd 就成为监听 fd。主要职责是监听新连接，不负责收发数据。\n2. 连接 fd\n参考代码中的 int clientfd = accept(sockfd, ...);。accept() 成功后会返回一个新的 fd：\nsockfd：负责监听新客户端 clientfd：负责和某个客户端通信 关系如下：\n┌→ clientfd 4 ←→ 客户端 A listenfd → accept() ─┼→ clientfd 5 ←→ 客户端 B └→ clientfd 6 ←→ 客户端 C 监听 fd 通常只有一个，但每建立一个客户端连接，服务端就会获得一个新的连接 fd。\n五、服务端代码执行流程 1. 创建 socket int sockfd = socket(AF_INET, SOCK_STREAM, 0); 作用：\n在内核中创建 socket 对象。 返回一个 fd。 当前还没有绑定 IP 和端口。 当前也没有开始监听。 更准确的变量名可以理解为：\nint listenfd; 因为它后面会被设置为监听 socket。\n2. 准备服务器地址 struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(2000); sin_family servaddr.sin_family = AF_INET; 表示使用 IPv4。\nINADDR_ANY servaddr.sin_addr.s_addr = htonl(INADDR_ANY); INADDR_ANY 表示监听本机所有 IPv4 网络接口，对应：\n0.0.0.0 假设服务器有多个 IP：\n127.0.0.1 192.168.1.10 10.0.0.10 绑定 0.0.0.0，表示这些本机地址上的 2000 端口都可以接收连接。\nhtons() servaddr.sin_port = htons(2000); htons 表示：\nhost to network short 它把主机字节序的 16 位端口号转换成网络字节序。\n同理：\nhtonl 表示把 32 位数据从主机字节序转换成网络字节序。\n端口范围是：\n0～65535 通常：\n0：让操作系统自动分配端口。 1～1023：传统意义上的特权端口。 1024～65535：普通程序通常可以使用。 但 1024 以上的端口也可能已经被其他程序占用。\n3. 绑定 IP 和端口 bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr)); bind() 将 socket 与本地 IP、端口建立联系：\nsockfd ←→ 0.0.0.0:2000 可以理解为：\n给迎宾人员安排具体的工作地点。\n如果端口已经被其他服务绑定，可能出现：\nAddress already in use 你的代码通过下面的方式查看错误：\nprintf(\u0026#34;bind error:%s\\n\u0026#34;, strerror(errno)); 不过如果 bind() 失败，程序理论上不应该继续执行 listen()。\n4. 进入监听状态 listen(sockfd, 10); 执行成功后，socket 从普通 socket 变成监听 socket。\n此时可以使用：\nss -lntp | grep \u0026#39;:2000\u0026#39; 查看监听状态。\n可能看到：\nLISTEN 0 10 0.0.0.0:2000 5. 接收客户端连接 int clientfd = accept( sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len ); 如果当前没有已经建立完成的客户端连接，阻塞模式下的 accept() 会暂停程序：\n程序阻塞在 accept() ↓ 等待客户端连接 ↓ 客户端完成 TCP 三次握手 ↓ accept() 返回 clientfd accept() 不会把原来的 sockfd 变成客户端连接。\n它会返回一个全新的 clientfd。\n六、代码中的三个服务端版本（代码文末附） 第一阶段：只处理一个客户端的一次数据 #if 0 int clientfd = accept(...); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); #endif 执行过程：\naccept 一个客户端 ↓ recv 一次 ↓ send 一次 ↓ 后面不再 accept 这个版本只能：\n接收一个客户端。 读取一次数据。 返回一次数据。 如果客户端想再次发送，服务端没有循环继续读取。\n第二阶段：循环接收客户端，但串行处理 #elif 0 while (1) { int clientfd = accept(...); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); } #endif 这里增加了：\nwhile (1) 因此服务端可以不断接收新客户端。\n但是它仍然是串行处理：\naccept 客户端 A ↓ recv 等待 A 发送数据 ↓ send 返回给 A ↓ 重新 accept 客户端 B 关键问题在这里：\nrecv(clientfd, buffer, 1024, 0); 默认情况下，recv() 是阻塞的。\n假设客户端 A 已经连接，但一直没有发送数据：\n服务端阻塞在 A 的 recv() 此时即使客户端 B 已经发起连接，应用程序也暂时无法回到 accept() 处理 B。\n第三阶段：一个客户端对应一个线程 当前实际编译的是 #else：\n#else while (1) { int clientfd = accept(...); pthread_t thid; pthread_create( \u0026amp;thid, NULL, client_thread, \u0026amp;clientfd ); } #endif 每接收一个客户端，创建一个线程。\n线程函数：\nvoid *client_thread(void *arg) { int clientfd = *(int *)arg; while (1) { char buffer[1024] = {0}; int count = recv( clientfd, buffer, 1024, 0 ); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send( clientfd, buffer, count, 0 ); } } 运行关系变成：\n主线程 │ ├─ accept 客户端 A │ └─ 创建线程 1 → recv(A) │ ├─ accept 客户端 B │ └─ 创建线程 2 → recv(B) │ └─ accept 客户端 C └─ 创建线程 3 → recv(C) 主线程只负责：\n不断执行 accept() 工作线程负责：\nrecv() → 处理数据 → send() 这样即使线程 1 阻塞在客户端 A 的 recv()，主线程仍然可以继续 accept() 客户端 B。\n准确来说，这是：\n一个连接对应一个线程。\n不是严格的”一次请求对应一个线程”，因为一个连接线程内部可以通过 while 处理多次请求。\n七、对于阶段三一个连接一个线程的问题 这种模型简单直观，但连接数量增大后会产生问题。\n1. 线程需要内存 每创建一个线程，都需要线程栈及相关管理资源。连接越多，线程越多，百万并发的时候\u0026hellip;\n2. 上下文切换成本 CPU 核心数量有限。\n假设服务器只有 8 个 CPU 核心，却创建了 5000 个线程，操作系统必须不停地：\n保存线程 A 的状态 切换到线程 B 保存线程 B 的状态 切换到线程 C 线程太多时，大量 CPU 时间可能消耗在线程调度上。\n3. 大部分线程可能只是在等待 很多网络连接并不是一直都有数据。\n例如即时通信中的长连接：\n客户端保持在线 但大部分时间没有发送消息 对应线程会长期阻塞在：\nrecv(clientfd, ...); 如果一个空闲连接占用一个线程，就会浪费大量线程资源。\n代码 #include \u0026lt;stdio.h\u0026gt; // printf #include \u0026lt;string.h\u0026gt; // memset #include \u0026lt;sys/socket.h\u0026gt; #include \u0026lt;arpa/inet.h\u0026gt; // htonl, htons #include \u0026lt;errno.h\u0026gt; // errno #include \u0026lt;netinet/in.h\u0026gt; // struct sockaddr_in #include \u0026lt;unistd.h\u0026gt; // close #include \u0026lt;pthread.h\u0026gt; void *client_thread(void *arg) { int clientfd = *(int *)arg; while (1) { char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); // 全部阻塞在 recv 上 printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); } } int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); // create socket 聘请迎宾者 // 绑定本地端口 struct sockaddr_in servaddr; servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 servaddr.sin_port = htons(2000); // 0-1023 系统默认，1024 之后都可以用，不能与其他的冲突 // 在哪个门迎宾，安排的位置 if (-1 == bind(sockfd, (struct sockaddr *)\u0026amp;servaddr, sizeof(struct sockaddr))) { printf(\u0026#34;bind error:%s\\n\u0026#34;, strerror(errno)); } // 登记绑定 listen(sockfd, 10); // 正式上榜 printf(\u0026#34;listen finished\\n\u0026#34;); struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); #if 0 printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); #elif 0 while (1) { printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); // 全部阻塞在 recv 上 printf(\u0026#34;RECV:%s\\n\u0026#34;, buffer); count = send(clientfd, buffer, count, 0); printf(\u0026#34;SEND:%s\\n\u0026#34;, count); } // 多个 io 如何接收数据 #else while (1) { printf(\u0026#34;accept\\n\u0026#34;); int clientfd = accept(sockfd, (struct sockaddr *)\u0026amp;clientaddr, \u0026amp;len); // 建立链接 printf(\u0026#34;accept finished\\n\u0026#34;); pthread_t thid; pthread_create(\u0026amp;thid, NULL, client_thread, \u0026amp;clientfd); } #endif getchar(); printf(\u0026#34;exit\\n\u0026#34;); return 0; } ","date":"2026-08-06T00:00:00+08:00","image":"/MyBlog/p/network-io-and-io-multiplexing/cover.svg","permalink":"/MyBlog/p/network-io-and-io-multiplexing/","title":"网络 I/O 与 I/O 多路复用"},{"content":"期末月回顾 时隔两个月，博客写了不少，经历也不少，这段时间打算重启这个记录吧。\n简明扼要地说说消失的这两个月我干了什么。在学校的时间大概就是期末月，平时完全不怎么学，度过了最 free 一学期的我，只能靠这最后一个月找补了。其缺点也是非常明显的，不知为何感觉复习得草草，期末的题都能应付了事，只不过很奇怪，为什么分数还是很低呢？没有认真学，这样的结果我也释怀了。下学期尽量认真学吧，每一门、每一科——除非真的是很无语、没有一点知识性的科目。\n下学期目标 希望下学期的主线编排还是在自己能力的提升和找实习上吧。希望绩点能到 3.8，这是我对下学期的 goal，应该朝着这个方向努力。也希望能如愿以偿找到一份好的实习，在寒假努努力、取取经，多涨涨经验。\n希望下学期也可以探索多样的分支，对 Quant 和 Infra 的理解也希望能并行。\n学习与收获 其实消失的这几个月我陷入了漩涡。具体到学习上，我觉得还好，因为有一个实习作为导向，无非就是强化力扣、强化八股、强化对项目的理解，这些都是可以日拱一卒的。\n此外，机缘巧合了解到 PKU 的 Infra 夏令营，这无疑是一个契机，让我了解到 Infra 相关的内容，并在一定程度上提供了学习指导。\n顺带一提感悟：难以想象 PKU 的学生接受的兴趣社团内容原来是这样。如此的课程设计，我只能在国外大学的网课上看到，譬如 CS61A。他们的天赋、他们的大学、他们的环境、他们的资源，正在加速分离与其他人的命运，令人艳羡吧。至少就我所知，中九是无法望其项背的。\n总归，网课讲得很玄幻，知识跳跃度很大，很难吸收理解。好在这个时代有了 AI，Assignment 的设置一步一步，慢慢地也不至于被拉下太远。\n状态与计划 状态不太好，是因为感觉每天十分单调，作息也不是很健康。对着电脑学习一会儿，总想着干点其他的事，继而脑雾产生。专注力真的是很重要的思维资产，大部分的专注力都喂狗了，哈哈哈哈。\n总体就是这样。希望还剩下一个月的暑假能够认真利用：跟上 Infra，认真做 Assignment，有自己的理解和复盘；对后端项目有所提升。就是这样，自己的身体也希望更加健康，对于一些旁枝的探索也要慢慢拓展。\n就是这样，共勉，加油！\n","date":"2026-08-01T22:26:00+08:00","image":"/MyBlog/p/july-summer-review-new-start/cover.svg","permalink":"/MyBlog/p/july-summer-review-new-start/","title":"暑假七月总结：从期末月到新起点"},{"content":"承接 Go 项目反推：Feed 流系统实战——数据库与 GORM，这一篇继续拆解 feedsystem_video_go 的核心 Feed 流：从推拉模型到游标分页，再到热榜、关注流、话题流与缓存策略。\n什么是 Feed 流 打开抖音/微博，往下刷，不断出来新内容，这就是 Feed 流。\n这个项目的 4 种 Feed 流 类型 说明 排序方式 最新视频 全站最新发布的视频 按时间倒序 热榜 最热门的视频 按热度排序 关注流 只看关注的人发的视频 按时间倒序 话题流 某个话题下的视频 按时间倒序 相关文件 backend/internal/feed/ ├── entity.go # 请求/响应结构体 ├── handler.go # HTTP处理 ├── service.go # 业务逻辑（缓存、singleflight） └── repo.go # 数据库查询 推模型 vs 拉模型 拉模型（这个项目用的） 发视频：只写Video表（不做额外操作） 刷视频：实时查询Video表（拉取数据） 数据流动：用户刷的时候才从 DB 拉。\n推模型 发视频：写Video表 + 把视频ID推送到所有粉丝的Feed列表里 刷视频：直接从自己的Feed列表读（不用查DB） 数据流动：发的时候就推给粉丝了，粉丝刷的时候直接读。\n推拉结合 普通用户发视频 → 推送给粉丝（粉丝少，推送成本低） 大V发视频 → 不推送，粉丝读的时候实时拉取 对比 拉模型 推模型 推拉结合 发视频成本 低 高（大V要推给几百万粉丝） 中 刷视频成本 高（每次都要查DB） 低（直接读列表） 中 实时性 实时 略有延迟 中 复杂度 简单 复杂 最复杂 场景 用户量小 用户量大 抖音/微博 这个项目用拉模型，因为是学习项目，用户量小，拉模型简单够用。\n游标分页 vs 页码分页 页码分页 第1页：GET /feed?page=1\u0026amp;size=10 第2页：GET /feed?page=2\u0026amp;size=10 对应 SQL：\nSELECT * FROM videos ORDER BY create_time DESC LIMIT 5 OFFSET 0 -- 第1页 SELECT * FROM videos ORDER BY create_time DESC LIMIT 5 OFFSET 5 -- 第2页 问题：数据变化时可能重复或漏数据\n数据库：[100, 90, 80, 70, 60, 50, 40, 30] 第1页（page=1, size=3）：跳过0条，取3条 → [100, 90, 80] 插入新视频110，数据库变成：[110, 100, 90, 80, 70, 60, 50, 40, 30] 第2页（page=2, size=3）：跳过3条，取3条 → [80, 70, 60] 视频80重复出现了！ 为什么？因为 OFFSET 是基于\u0026quot;位置\u0026quot;的，不是基于\u0026quot;数据\u0026quot;的。新视频插入后，原来第 4 个位置的视频 80 变成了第 5 个，但 OFFSET 还是 3，所以又取到了。\n游标分页（这个项目用的） 第1页：GET /feed?limit=10 返回：video_list + next_time=1000 第2页：GET /feed?limit=10\u0026amp;latest_time=1000 返回：video_list + next_time=900 对应 SQL：\nSELECT * FROM videos ORDER BY create_time DESC LIMIT 10 SELECT * FROM videos WHERE create_time \u0026lt; 1000 ORDER BY create_time DESC LIMIT 10 为什么游标分页不会重复？\n第1页：WHERE create_time \u0026lt; now() LIMIT 3 返回 [100, 90, 80] next_time = 80的时间 插入110 第2页：WHERE create_time \u0026lt; 80的时间 LIMIT 3 返回 [70, 60, 50] 没有重复！ 游标是基于数据的值（时间），不是位置。新视频插入不影响查询条件。\n对比 页码分页 游标分页 请求方式 page=2 latest_time=1000 数据变化时 可能重复或漏数据 不会重复 SQL OFFSET 跳过 WHERE 条件过滤 场景 后台管理系统 Feed 流、时间线 游标分页的实现 服务器代码 func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) { query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Order(\u0026#34;create_time DESC\u0026#34;) // 关键：如果有游标，加上WHERE条件 if !latestBefore.IsZero() { query = query.Where(\u0026#34;create_time \u0026lt; ?\u0026#34;, latestBefore) } query.Limit(limit).Find(\u0026amp;videos) return videos, nil } 游标怎么生成的 // 取本页最后一条视频的时间作为游标 var nextTime int64 if len(baseVideos) \u0026gt; 0 { nextTime = baseVideos[len(baseVideos)-1].CreateTime.UnixMilli() } 本页最后一条视频的时间 = 下一页的起点。\n响应结构 type ListLatestResponse struct { VideoList []FeedVideoItem `json:\u0026#34;video_list\u0026#34;` NextTime int64 `json:\u0026#34;next_time\u0026#34;` // 游标 HasMore bool `json:\u0026#34;has_more\u0026#34;` // 还有没有更多 } 完整流程 客户端：GET /feed?limit=10 ↓ 服务器：SELECT * FROM videos ORDER BY create_time DESC LIMIT 10 ↓ 返回：video_list + next_time = 本页最后一条的时间 ↓ 客户端：GET /feed?limit=10\u0026amp;latest_time=next_time ↓ 服务器：SELECT * FROM videos WHERE create_time \u0026lt; next_time ORDER BY create_time DESC LIMIT 10 ↓ 返回：video_list + next_time = 新的游标 ↓ ...循环，直到 has_more = false 热榜实现 热榜排序算法 query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}). Order(\u0026#34;popularity DESC, create_time DESC, id DESC\u0026#34;) 排序规则：热度高的优先，热度相同按时间倒序，时间也相同按 ID 倒序。\n热度分数怎么算的 func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { now := time.Now().UTC().Truncate(time.Minute) windowKey := cache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, now.Format(\u0026#34;200601021504\u0026#34;)) member := strconv.FormatUint(uint64(id), 10) cache.ZincrBy(opCtx, windowKey, member, float64(change)) // 热度+change cache.Expire(opCtx, windowKey, 2*time.Hour) } 每次点赞/评论/关注，热度+1。\n热榜查询流程（冷热分离） func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, ...) { // 1. 确定时间窗口（最近60分钟） asOf := time.Now().UTC().Truncate(time.Minute) // 2. 生成60个ZSET的key keys := make([]string, 0, 60) for i := 0; i \u0026lt; 60; i++ { keys = append(keys, f.rediscache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, asOf.Add(-time.Duration(i)*time.Minute).Format(\u0026#34;200601021504\u0026#34;))) } // 3. 合并成一个ZSET（快照） dest := f.rediscache.Key(\u0026#34;hot:video:merge:1m:%s\u0026#34;, asOf.Format(\u0026#34;200601021504\u0026#34;)) f.rediscache.ZUnionStore(opCtx, dest, keys, \u0026#34;SUM\u0026#34;) // 4. 从合并后的ZSET取top N members, _ := f.rediscache.ZRevRange(opCtx, dest, start, stop) // 5. 根据ID去查视频详情 videos, _ := f.repo.GetByIDs(ctx, ids) return videos } 快照 key 是什么 快照 key 就是\u0026quot;合并结果的缓存\u0026quot;。\n没有快照key： 用户A请求 → 合并60个ZSET → 返回 用户B请求 → 合并60个ZSET → 返回 100个用户请求 → 合并100次 → 浪费！ 有快照key： 用户A请求 → 合并60个ZSET → 存到快照key → 返回 用户B请求 → 发现快照key已存在 → 直接用 → 返回 100个用户请求 → 只合并1次 → 省资源！ 代码：\ndest := f.rediscache.Key(\u0026#34;hot:video:merge:1m:%s\u0026#34;, asOf.Format(\u0026#34;200601021504\u0026#34;)) exists, _ := f.rediscache.Exists(opCtx, dest) if !exists { f.rediscache.ZUnionStore(opCtx, dest, keys, \u0026#34;SUM\u0026#34;) f.rediscache.Expire(opCtx, dest, 2*time.Minute) } members, _ := f.rediscache.ZRevRange(opCtx, dest, start, stop) 快照 key 2 分钟后过期，下次请求重新合并，拿到最新数据。\n关注流实现 核心代码 func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) { var videos []*video.Video query := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Order(\u0026#34;create_time DESC\u0026#34;) if viewerAccountID \u0026gt; 0 { // 子查询：找到我关注的所有人 followingSubQuery := repo.db.WithContext(ctx). Model(\u0026amp;social.Social{}). Select(\u0026#34;vlogger_id\u0026#34;). Where(\u0026#34;follower_id = ?\u0026#34;, viewerAccountID) // 只查这些人发的视频 query = query.Where(\u0026#34;author_id IN (?)\u0026#34;, followingSubQuery) } if !latestBefore.IsZero() { query = query.Where(\u0026#34;create_time \u0026lt; ?\u0026#34;, latestBefore) } query.Limit(limit).Find(\u0026amp;videos) return videos, nil } 等价 SQL SELECT * FROM videos WHERE author_id IN ( SELECT vlogger_id FROM socials WHERE follower_id = 123 -- 我关注的人 ) AND create_time \u0026lt; 1000 -- 游标 ORDER BY create_time DESC LIMIT 10 子查询是什么 子查询就是\u0026quot;查询里的查询\u0026quot;，拆成两步理解：\n第1步（子查询）： SELECT vlogger_id FROM socials WHERE follower_id = 123 → 结果：[456, 789]（用户A关注的人） 第2步（主查询）： SELECT * FROM videos WHERE author_id IN (456, 789) → 结果：视频100（B发的）、视频200（C发的） 子查询是一次 SQL 搞定，数据库内部优化，比两条 SQL 更快。\n流程 用户A关注了B和C ↓ 子查询：从social表找到 [456, 789] ↓ 主查询：从video表找 author_id 是 456 或 789 的视频 ↓ 返回：视频100（B发的）、视频200（C发的） ↓ 用户D的视频300不会出现（A没关注D） 话题流实现 核心代码 func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) { var videos []*video.Video err := repo.db.WithContext(ctx).Model(\u0026amp;video.Video{}).Table(\u0026#34;videos\u0026#34;). Joins(\u0026#34;JOIN video_tags ON video_tags.video_id = videos.id\u0026#34;). Joins(\u0026#34;JOIN tags ON tags.id = video_tags.tag_id\u0026#34;). Where(\u0026#34;tags.name = ?\u0026#34;, tagName). Order(\u0026#34;videos.create_time desc\u0026#34;). Limit(limit). Find(\u0026amp;videos).Error return videos, err } 等价 SQL SELECT videos.* FROM videos JOIN video_tags ON video_tags.video_id = videos.id JOIN tags ON tags.id = video_tags.tag_id WHERE tags.name = \u0026#39;Go语言\u0026#39; ORDER BY videos.create_time DESC LIMIT 10 三张表的关系 tags表： ┌────┬──────────┐ │ id │ name │ ├────┼──────────┤ │ 1 │ Go语言 │ │ 2 │ Python │ └────┴──────────┘ video_tags表（多对多关联）： ┌──────────┬─────────┐ │ video_id │ tag_id │ ├──────────┼─────────┤ │ 100 │ 1 │ ← 视频100有\u0026#34;Go语言\u0026#34;标签 │ 100 │ 2 │ ← 视频100也有\u0026#34;Python\u0026#34;标签 │ 200 │ 1 │ ← 视频200有\u0026#34;Go语言\u0026#34;标签 └──────────┴─────────┘ videos表： ┌────┬──────────┐ │ id │ title │ ├────┼──────────┤ │ 100 │ Go入门 │ │ 200 │ 并发编程 │ └────┴──────────┘ 查询流程 1. 从tags表找到\u0026#34;Go语言\u0026#34;的id = 1 2. 从video_tags表找到tag_id = 1的video_id = [100, 200] 3. 从videos表找到id = [100, 200]的视频 JOIN一次搞定。 三级缓存架构 这个项目在 Feed 查询里用了三级缓存：\n请求 → L1本地缓存 → L2 Redis → L3 MySQL ↓ 快 ↓ 较快 ↓ 慢 纳秒级 毫秒级 毫秒级 代码 type FeedService struct { localcache *cache.Cache // L1：本地缓存（进程内存，3秒过期） rediscache *rediscache.Client // L2：Redis缓存 repo *FeedRepository // L3：MySQL } // L1：本地缓存 localcache: cache.New(3*time.Second, 5*time.Second) // L2：Redis缓存（50ms超时） cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) // L3：MySQL videos, err := f.repo.GetByIDs(ctx, ids) 查询流程 请求来了 ↓ 查L1本地缓存（进程内存） ├── 命中 → 直接返回（最快） └── 没命中 ↓ 查L2 Redis（50ms超时） ├── 命中 → 写回L1 → 返回 └── 没命中 ↓ 查L3 MySQL → 写回L2 Redis → 写回L1 → 返回 singleflight 防并发 什么是 singleflight Go 内置的工具，保证同一个请求只执行一次，其他并发请求等着用结果。\n场景 100个用户同时请求热榜 ↓ 没有singleflight： 用户1：查DB → 返回 用户2：查DB → 返回 ... 用户100：查DB → 返回 DB被查了100次！ 有singleflight： 用户1：查DB → 返回 用户2-100：等着 → 用户1查完后直接用结果 DB只被查了1次！ 代码 type FeedService struct { requestGroup singleflight.Group } v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) { return f.repo.GetByIDs(ctx, []uint{videoID}) }) 第 1 个请求：执行 func，查 DB 其他请求：同一个 sfKey，等着，拿到同样的结果 sfKey 是什么 请求的唯一标识：\nsfKey := f.rediscache.Key(\u0026#34;sf:entity:%d\u0026#34;, videoID) // 结果：\u0026#34;sf:entity:123\u0026#34; 同一个视频 ID 的请求，sfKey 相同，会共享结果。\n和分布式锁的对比 分布式锁 singleflight 作用范围 多台机器 单机内 实现 Redis Go 内存 复杂度 高 低 场景 分布式系统 单机内防并发 面试要点总结 Q: 推模型和拉模型的区别？ A: 推是发视频时推送给粉丝，拉是刷视频时实时查 DB。推的读快写慢，拉的读慢写快。抖音用推拉结合。\nQ: 游标分页和页码分页的区别？ A: 游标用 WHERE 条件过滤，不会重复；页码用 OFFSET 跳过，数据变化时可能重复。Feed 流用游标分页。\nQ: 热榜怎么实现的？ A: ZSET 按分钟分窗口，点赞时热度+1，查询时合并 60 个窗口算总分，快照 key 缓存合并结果。\nQ: 关注流怎么实现的？ A: 子查询找关注列表，再查这些人发的视频。一次 SQL 搞定。\nQ: 话题流怎么实现的？ A: 三表 JOIN（videos + video_tags + tags），查某个标签下的视频。\nQ: 什么是 singleflight？ A: Go 内置工具，保证同一个请求只执行一次，其他并发请求等着用结果。和分布式锁类似，但作用于单机内。\nQ: 三级缓存是什么？ A: L1 本地缓存（进程内存）→ L2 Redis → L3 MySQL。先查快的，没命中再查慢的。\n","date":"2026-07-29T00:00:00+08:00","image":"/MyBlog/p/go-feed-design/cover.svg","permalink":"/MyBlog/p/go-feed-design/","title":"Go 项目反推：Feed 流系统实战——Feed 流设计"},{"content":"承接 Go 项目反推：Feed 流系统实战——Feed 流设计，这一篇继续拆解 feedsystem_video_go 的两个关键能力：用 SSE 实时推送通知，以及用分片上传和断点续传处理大文件。\n阶段6：SSE实时推送 SSE 是什么 SSE = Server-Sent Events，服务器主动给客户端推送消息。\n就像外卖 APP 的订单状态：\n你下单 → 服务器推送\u0026#34;商家已接单\u0026#34; → 服务器推送\u0026#34;骑手已取餐\u0026#34; → 服务器推送\u0026#34;骑手已送达\u0026#34; 客户端不用一直问\u0026quot;好了没？好了没？\u0026quot;，服务器有消息就主动推。\nSSE vs WebSocket SSE WebSocket 方向 服务器 → 客户端（单向） 双向 协议 HTTP 独立协议 复杂度 简单 复杂 场景 通知、状态更新、新闻推送 聊天、游戏、实时协作 这个项目用 SSE 推送通知（点赞、评论、关注），因为只需要服务器→客户端，不需要客户端→服务器。\n为什么用 SSE 不用轮询 方案 做法 问题 轮询 客户端每隔几秒问一次\u0026quot;有新消息吗？\u0026quot; 浪费资源、实时性差、大部分请求返回空 SSE 服务器有消息就推，没有就等着 省资源、实时性高 项目里 SSE 用在哪 用户A给用户B的视频点赞 ↓ LikeWorker处理点赞事件 ↓ NotificationWorker收到MQ消息 ↓ 创建Notification记录，写入DB ↓ SSEHub.Push(userID, notification) ↓ 用户B的浏览器收到推送，实时显示\u0026#34;xxx点赞了你的视频\u0026#34; SSEHub 数据结构 type SSEHub struct { mu sync.RWMutex clients map[uint][]chan *Notification // userID -\u0026gt; 多个通知channel db *gorm.DB } 数据结构：\nmap[uint][]chan *Notification 用户1 → [channel1, channel2] （可能多个设备） 用户2 → [channel3] 用户3 → [channel4, channel5, channel6] （三个设备） 代码实现 相关文件 backend/internal/worker/ssehub.go # SSE Hub，管理所有SSE连接 backend/internal/worker/notificationworker.go # 通知Worker，消费MQ消息并推送 Notification 表结构 type Notification struct { ID uint RecipientID uint // 接收者ID SenderID uint // 发送者ID Type string // 通知类型：like、comment、follow TargetID uint // 目标ID（视频ID、用户ID） Content string // 通知内容 IsRead bool // 是否已读 CreatedAt time.Time } SSEHub 核心方法 // 订阅：用户连接时调用 func (h *SSEHub) Subscribe(userID uint) chan *Notification { ch := make(chan *Notification, 20) // 缓冲区20 h.mu.Lock() // 写锁 h.clients[userID] = append(h.clients[userID], ch) h.mu.Unlock() return ch } // 取消订阅：用户断开时调用 func (h *SSEHub) Unsubscribe(userID uint, ch chan *Notification) { h.mu.Lock() // 写锁 defer h.mu.Unlock() chs := h.clients[userID] for i, c := range chs { if c == ch { chs = append(chs[:i], chs[i+1:]...) if len(chs) == 0 { delete(h.clients, userID) } else { h.clients[userID] = chs } close(c) return } } } // 推送：有新通知时调用 func (h *SSEHub) Push(userID uint, n *Notification) { h.mu.RLock() // 读锁 defer h.mu.RUnlock() chs, ok := h.clients[userID] if !ok { return } for _, ch := range chs { select { case ch \u0026lt;- n: // 往channel里塞消息 default: // 塞不进去就跳过（防阻塞） } } } SSEHandler - 处理 SSE 连接 func (h *SSEHub) SSEHandler(c *gin.Context) { userID, ok := sseAccountID(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{\u0026#34;error\u0026#34;: \u0026#34;invalid account\u0026#34;}) return } // 设置SSE响应头 c.Writer.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;text/event-stream\u0026#34;) c.Writer.Header().Set(\u0026#34;Cache-Control\u0026#34;, \u0026#34;no-cache\u0026#34;) c.Writer.Header().Set(\u0026#34;Connection\u0026#34;, \u0026#34;keep-alive\u0026#34;) c.Writer.WriteHeader(http.StatusOK) // 订阅通知 ch := h.Subscribe(userID) defer h.Unsubscribe(userID, ch) ctx := c.Request.Context() flusher, _ := c.Writer.(http.Flusher) for { select { case \u0026lt;-ctx.Done(): // 客户端断开 return case n, ok := \u0026lt;-ch: // 收到通知 if !ok { return } b, _ := json.Marshal(n) fmt.Fprintf(c.Writer, \u0026#34;data: %s\\n\\n\u0026#34;, b) // SSE格式 if flusher != nil { flusher.Flush() // 立刻发送给客户端 } case \u0026lt;-time.After(30 * time.Second): // 心跳 fmt.Fprintf(c.Writer, \u0026#34;: keepalive\\n\\n\u0026#34;) if flusher != nil { flusher.Flush() } } } } Push 为什么用 RLock 不用 Lock 什么是读写锁（RWMutex） 普通锁（Mutex）：同一时刻只能一个人进去，不管是读还是写。\n读写锁（RWMutex）：\n多个人可以同时读（RLock） 写的时候只能一个人进去，其他人不能读也不能写（Lock） 对比 操作 做什么 用什么锁 Push 读 map，往 channel 塞消息 RLock（读锁） Subscribe 往 map 里加一个 channel Lock（写锁） Unsubscribe 从 map 里删一个 channel Lock（写锁） 为什么 Push 能用 RLock Push 只做两件事：\n从 map 里找到用户的 channel（读） 往 channel 里塞消息（不改 map） map 结构没变，所以多个 Push 可以同时进行。\n一句话：读用 RLock（可以并发），写用 Lock（必须独占）。 场景对比 场景：100个用户同时收到通知 用普通锁（Mutex）： Push1加锁 → 推送 → 解锁 Push2加锁 → 推送 → 解锁 ... 一个一个来，慢 用读写锁（RWMutex）： Push1加RLock → 推送 Push2加RLock → 推送 Push3加RLock → 推送 ... 100个同时推，快 心跳机制 什么是心跳 SSE 连接可能因为网络问题断开，客户端不知道。\n解决方案：每 30 秒发一次空消息（keepalive），告诉客户端\u0026quot;我还活着\u0026quot;。\ncase \u0026lt;-time.After(30 * time.Second): fmt.Fprintf(c.Writer, \u0026#34;: keepalive\\n\\n\u0026#34;) flusher.Flush() 为什么需要心跳 没有心跳： 客户端连接服务器 → 网络抖动 → 连接断了 客户端不知道断了 → 一直等 → 漏掉通知 有心跳： 客户端连接服务器 → 每30秒收到keepalive 30秒没收到 → 知道断了 → 重新连接 select 里的 default 防阻塞 for _, ch := range chs { select { case ch \u0026lt;- n: // 往channel里塞消息 default: // 塞不进去就跳过 } } channel 缓冲区满了（20条），塞不进去怎么办？\n没有 default：阻塞等着，Push 卡住，其他用户的推送也卡住 有 default：直接跳过，不等，保证其他用户能正常收到推送 这是防阻塞设计：一个用户的消息堆积不影响其他用户。\nNotificationWorker 处理流程 func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error { routingKey := d.RoutingKey var notif *Notification switch { case routingKey == \u0026#34;like.like\u0026#34;: // 解析点赞事件 // 查询视频作者ID // 如果是自己给自己点赞，不通知 notif = \u0026amp;Notification{RecipientID: authorID, SenderID: evt.UserID, Type: \u0026#34;like\u0026#34;, Content: \u0026#34;点赞了你的视频\u0026#34;} case routingKey == \u0026#34;comment.publish\u0026#34;: // 解析评论事件 // 查询视频作者ID // 如果是自己评论自己，不通知 notif = \u0026amp;Notification{RecipientID: authorID, SenderID: evt.AuthorID, Type: \u0026#34;comment\u0026#34;, Content: \u0026#34;评论了你的视频\u0026#34;} case routingKey == \u0026#34;social.follow\u0026#34;: // 解析关注事件 notif = \u0026amp;Notification{RecipientID: evt.VloggerID, SenderID: evt.FollowerID, Type: \u0026#34;follow\u0026#34;, Content: \u0026#34;关注了你\u0026#34;} } if notif == nil { return nil } // 写入DB w.db.Create(notif) // 实时推送给用户 w.hub.Push(notif.RecipientID, notif) return nil } 接口设计 接口 方法 作用 /stream GET SSE 连接，实时接收通知 /list POST 查询通知列表（最近 50 条） /markRead POST 标记已读 /unreadCount POST 查询未读数量 阶段7：分片上传 为什么需要分片上传 视频文件可能很大（几十 MB 到 200MB），一次上传的问题：\n一次上传100MB： 网络抖动 → 上传到99%断了 → 重头再来 用户体验极差 分片上传：\n100MB文件，分成5MB一片，共20片 第1片上传成功 ✓ 第2片上传成功 ✓ ... 第17片网络断了 ✗ 重新连接 → 只需要重新上传第17片，1-16不用重传 分片上传流程 整体流程 1. 客户端：计算文件MD5 → 调用Init接口 → 获得upload_id + 已上传分片列表 2. 客户端：逐片上传（跳过已上传的） → 每片带MD5校验 3. 客户端：全部上传完 → 调用Complete接口 → 服务器合并分片 → 返回视频URL 流程图 客户端 服务器 | | |--- Init(文件名, 大小, MD5) -----------\u0026gt;| |\u0026lt;-- upload_id + 已上传列表 -------------| | | |--- UploadChunk(分片0, MD5) -----------\u0026gt;| 保存到临时目录 |\u0026lt;-- chunk_index: 0 ----------------------| | | |--- UploadChunk(分片1, MD5) -----------\u0026gt;| 保存到临时目录 |\u0026lt;-- chunk_index: 1 ----------------------| | | | ...（中间可能断线重连） | | | |--- Complete(upload_id) ---------------\u0026gt;| 合并所有分片 |\u0026lt;-- url: 视频地址 -----------------------| 相关文件 backend/internal/video/chunk_entity.go # 数据结构定义 backend/internal/video/chunk_handler.go # 业务逻辑 核心数据结构 ChunkUploadSession（上传会话） const ChunkSize = 5 \u0026lt;\u0026lt; 20 // 5 MB type ChunkUploadSession struct { UploadID string // 上传会话ID（随机生成） AccountID uint // 上传者ID Filename string // 原始文件名 FileSize int64 // 文件总大小 ChunkSize int64 // 每片大小（默认5MB） TotalChunks int // 总片数 FileHash string // 整个文件的MD5 UploadedBits []bool // 每片是否已上传（位图） } UploadedBits 是什么 UploadedBits: []bool{true, true, true, false, false} 这个数组表示：\n分片 0：已上传 ✓ 分片 1：已上传 ✓ 分片 2：已上传 ✓ 分片 3：未上传 分片 4：未上传 用 bool 数组而不是存已上传的分片列表，好处是：\n判断某片是否已上传：O(1)，直接 index 判断是否全部上传完：遍历一次，O(n) 两个辅助方法 // 返回已上传的分片索引列表 func (s *ChunkUploadSession) UploadedChunks() []int { var indices []int for i, uploaded := range s.UploadedBits { if uploaded { indices = append(indices, i) } } return indices } // 判断是否全部上传完 func (s *ChunkUploadSession) IsComplete() bool { for _, b := range s.UploadedBits { if !b { return false } } return true } 会话存在哪：Redis 上传会话存在 Redis，不是 MySQL，原因是：\n存储 读写速度 持久化 适合存什么 Redis 微秒级 可配置过期 临时状态（上传进度） MySQL 毫秒级 永久 持久数据（用户信息、视频记录） 上传会话是临时的（24 小时过期），用 Redis 更合适。\nRedis Key 设计 // 上传会话 func (h *ChunkUploadHandler) sessionKey(uploadID string) string { return h.cache.Key(\u0026#34;chunk_upload:%s\u0026#34;, uploadID) } // 文件哈希 → upload_id 的映射（用于断点续传） func (h *ChunkUploadHandler) hashKey(accountID uint, fileHash string) string { return h.cache.Key(\u0026#34;chunk_upload_hash:%d:%s\u0026#34;, accountID, fileHash) } 两个 Key 的作用：\nchunk_upload:\u0026lt;upload_id\u0026gt;：存完整的上传会话 chunk_upload_hash:\u0026lt;account_id\u0026gt;:\u0026lt;file_hash\u0026gt;：文件哈希到 upload_id 的映射，用于断点续传 四个接口详解 接口 1：InitChunkUpload（初始化上传） func (h *ChunkUploadHandler) InitChunkUpload(c *gin.Context) 做了什么：\n校验请求参数（文件名、大小、哈希、总片数） 检查文件大小是否超过 200MB 限制 断点续传检查：用文件哈希查 Redis，如果找到之前的会话，直接返回 创建新会话，存入 Redis 返回 upload_id + 已上传列表（新上传为空） 断点续传的实现：\n// 检查是否有同文件的旧会话 hashKey := h.hashKey(accountID, req.FileHash) existingID, err := h.cache.GetBytes(c.Request.Context(), hashKey) if err == nil \u0026amp;\u0026amp; len(existingID) \u0026gt; 0 { session, sessErr := h.getSession(c, string(existingID)) if sessErr == nil { // 找到了！返回旧的upload_id和已上传列表 c.JSON(http.StatusOK, gin.H{ \u0026#34;upload_id\u0026#34;: session.UploadID, \u0026#34;uploaded_chunks\u0026#34;: session.UploadedChunks(), }) return } } 为什么用文件哈希做断点续传？\n用户上传 video.mp4（100MB，MD5: abc123） → Init，upload_id: xxx，开始上传 → 上传了15片，浏览器关闭 用户重新选择同一个 video.mp4 → Init，参数里带同样的 file_hash: abc123 → 服务器查Redis：chunk_upload_hash:\u0026lt;uid\u0026gt;:abc123 → xxx → 找到旧会话！返回已上传的15片 → 客户端跳过这15片，只上传剩下的5片 接口 2：UploadChunk（上传单片） func (h *ChunkUploadHandler) UploadChunk(c *gin.Context) 做了什么：\n从表单获取 upload_id、chunk_index、chunk_hash 查 Redis 获取会话 校验：会话存在、是本人上传、分片索引合法 幂等检查：如果这片已经上传过，直接返回成功（不重复处理） 读取上传的文件内容，计算 MD5 哈希校验：对比客户端传的 chunk_hash 和实际计算的 MD5 保存分片到临时目录：.run/uploads/tmp/\u0026lt;upload_id\u0026gt;/\u0026lt;chunk_index\u0026gt; 更新 Redis 中的会话状态 幂等设计：\nif session.UploadedBits[req.ChunkIndex] { c.JSON(http.StatusOK, gin.H{\u0026#34;chunk_index\u0026#34;: req.ChunkIndex}) return } 如果分片已上传过，直接返回成功，不重复处理。这保证了重试的安全性。\n哈希校验：\nhash := md5.New() io.Copy(hash, chunkFile) actualHash := fmt.Sprintf(\u0026#34;%x\u0026#34;, hash.Sum(nil)) if actualHash != req.ChunkHash { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: \u0026#34;chunk hash mismatch\u0026#34;}) return } 每个分片都有 MD5 校验，防止传输过程中数据损坏。\n分片存储位置：\n.run/uploads/tmp/ └── \u0026lt;upload_id\u0026gt;/ ├── 0 ← 第0片 ├── 1 ← 第1片 ├── 2 ← 第2片 └── ... 接口 3：ChunkStatus（查询上传状态） func (h *ChunkUploadHandler) ChunkStatus(c *gin.Context) 返回：upload_id + 已上传分片列表 + 总片数。\n用于客户端轮询或断线后查询进度。\n接口 4：CompleteChunkUpload（完成上传） func (h *ChunkUploadHandler) CompleteChunkUpload(c *gin.Context) 做了什么：\n校验会话、校验是否本人 检查是否所有分片都已上传（IsComplete()） 如果有未上传的分片，返回错误和缺失信息 合并分片：按顺序读取所有分片，写入最终文件 清理临时文件和 Redis 会话 返回视频 URL 合并过程：\ntmpDir := filepath.Join(\u0026#34;.run\u0026#34;, \u0026#34;uploads\u0026#34;, \u0026#34;tmp\u0026#34;, req.UploadID) for i := 0; i \u0026lt; session.TotalChunks; i++ { chunkPath := filepath.Join(tmpDir, fmt.Sprintf(\u0026#34;%d\u0026#34;, i)) cf, _ := os.Open(chunkPath) io.Copy(finalFile, cf) // 按顺序拼接 cf.Close() } 最终文件位置：\n.run/uploads/ └── videos/ └── \u0026lt;account_id\u0026gt;/ └── \u0026lt;date\u0026gt;/ └── \u0026lt;random\u0026gt;.mp4 清理：\n// 删除临时分片 os.RemoveAll(tmpDir) // 删除Redis会话 h.cache.Del(ctx, sessionKey) h.cache.Del(ctx, hashKey) 安全设计 文件大小限制 const maxSize = 200 \u0026lt;\u0026lt; 20 // 200MB if req.FileSize \u0026gt; maxSize { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: \u0026#34;file size exceeds 200MB limit\u0026#34;}) return } 权限校验 每个接口都检查：会话存在 + 是本人操作。\naccountID, _ := jwt.GetAccountID(c) if session.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{\u0026#34;error\u0026#34;: \u0026#34;forbidden\u0026#34;}) return } 分片哈希校验 每个分片上传时都验证 MD5，防止数据损坏。\n接口总结 接口 方法 作用 /chunk/init POST 初始化上传，返回 upload_id /chunk/upload POST 上传单个分片 /chunk/status POST 查询上传状态 /chunk/complete POST 完成上传，合并分片 面试要点总结 Q: SSE 是什么？ A: Server-Sent Events，服务器主动推送消息给客户端，单向，基于 HTTP。场景：通知、状态更新、新闻推送。\nQ: SSE 和 WebSocket 怎么选？ A: 只要服务器推用 SSE（简单），需要双向通信用 WebSocket（复杂）。这个项目用 SSE 推送通知，因为只需要服务器→客户端。\nQ: 为什么用 SSE 不用轮询？ A: 轮询客户端每隔几秒问一次，浪费资源、实时性差。SSE 服务器有消息就推，省资源、实时性高。\nQ: 心跳机制是什么？ A: 每 30 秒发一次空消息（keepalive），告诉客户端\u0026quot;我还活着\u0026quot;。客户端 30 秒没收到，知道连接断了，重新连接。\nQ: Push 为什么用 RLock？ A: Push 只读 map 不改 map，用读锁可以多个 Push 并发。Subscribe/Unsubscribe 要改 map，用写锁必须独占。\nQ: select 里的 default 是干什么的？ A: 防阻塞。channel 满了塞不进去就跳过，保证一个用户的消息堆积不影响其他用户。\nQ: 为什么要分片上传？ A: 大文件一次上传，网络断了要重头来。分片后断点续传，只需重传失败的分片。\nQ: 断点续传怎么实现？ A: 用文件 MD5 做唯一标识，Init 时查 Redis 是否有同文件的旧会话。有就返回已上传列表，客户端跳过这些分片。\nQ: 上传会话存在哪？为什么？ A: 存 Redis，因为是临时状态（24 小时过期），读写快。MySQL 存持久数据。\nQ: 怎么保证分片数据没损坏？ A: 每片上传时计算 MD5，和客户端传的 chunk_hash 对比，不匹配就拒绝。\nQ: 分片重复上传怎么办？ A: 幂等设计——检查 UploadedBits，已上传的分片直接返回成功，不重复处理。\nQ: 会话里 UploadedBits 是什么？ A: bool 数组，每个元素对应一个分片是否已上传。长度等于总片数，直接用下标访问，O(1) 判断某片是否已上传。\n","date":"2026-07-29T00:00:00+08:00","image":"/MyBlog/p/go-feed-sse-upload/cover.svg","permalink":"/MyBlog/p/go-feed-sse-upload/","title":"Go 项目反推：Feed 流系统实战——SSE 实时推送与分片上传"},{"content":"承接 Go 项目反推：Feed 流系统实战——SSE 实时推送与分片上传，这一篇回到工程全景，梳理 feedsystem_video_go 的容器编排、配置管理、性能分析、持续集成与本地启动流程。\nDocker Compose（容器编排） 什么是 Docker Compose 把所有服务打包成容器，一键启动。\n没有 Docker Compose：\n手动启动MySQL → 手动启动Redis → 手动启动RabbitMQ → 手动启动Backend → 手动启动Worker → 手动启动Frontend 每次都要敲6条命令，还要记住启动顺序 有 Docker Compose：\ndocker compose up -d 一键启动所有服务 这个项目的 Docker Compose services: mysql: # 数据库 redis: # 缓存 rabbitmq: # 消息队列 backend: # API进程 worker: # Worker进程 frontend: # 前端 相关文件：~/dev/projects/feedsystem_video_go/docker-compose.yml\ndepends_on + healthcheck（保证启动顺序） depends_on 是什么 告诉 Docker：某个服务依赖其他服务，要等依赖的服务启动后才能启动自己。\nbackend: depends_on: mysql: condition: service_healthy # MySQL健康了才启动backend redis: condition: service_healthy # Redis健康了才启动backend rabbitmq: condition: service_healthy # RabbitMQ健康了才启动backend healthcheck 是什么 判断服务是否\u0026quot;健康\u0026quot;（真正能接受连接）。\nmysql: healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;mysqladmin ping -h 127.0.0.1 -uroot -p123456\u0026#34;] interval: 5s # 每5秒检查一次 timeout: 5s # 超时5秒 retries: 20 # 重试20次 MySQL 启动后，每 5 秒 ping 一次，连续 ping 成功才算\u0026quot;健康\u0026quot;。\n为什么要 healthcheck 没有healthcheck： MySQL容器启动了 → backend立刻启动 但MySQL还没初始化完 → backend连接失败 → 崩溃 有healthcheck： MySQL容器启动了 → 等MySQL真正能接受连接 → backend再启动 所有服务的 healthcheck 服务 healthcheck 测试 作用 mysql mysqladmin ping 确认 MySQL 能接受连接 redis redis-cli ping 确认 Redis 能接受连接 rabbitmq rabbitmq-diagnostics ping 确认 RabbitMQ 能接受连接 backend wget http://127.0.0.1:8080/healthz 确认 API 进程正常 worker pgrep worker 确认 Worker 进程存在 frontend wget http://127.0.0.1:80/ 确认前端能访问 配置管理（优先级） 配置文件位置 ~/dev/projects/feedsystem_video_go/backend/configs/ ├── config.yaml # 本地开发用（MySQL端口3306） ├── config.compose-local.yaml # Docker Compose本地用（MySQL端口3307） └── config.docker.yaml # Docker部署用（容器内部通信） 三个配置文件的区别 文件 MySQL 端口 用途 config.yaml 3306 本地开发，MySQL 装在本机 config.compose-local.yaml 3307 Docker Compose，MySQL 容器映射到 3307 config.docker.yaml 3306 Docker 部署，容器内部通信 配置加载优先级 环境变量 \u0026gt; .env文件 \u0026gt; YAML文件 来源 举例 优先级 环境变量 export MYSQL_DATABASE=feedsystem 最高 .env 文件 MYSQL_DATABASE=feedsystem 中 YAML 文件 database.dbname: feedsystem 最低 为什么要这样设计 开发环境： 用YAML文件，配置写死在代码里，方便开发 Docker部署： 用环境变量覆盖，不用改配置文件 docker run -e MYSQL_DATABASE=feedsystem -e JWT_SECRET=*** ... 环境变量覆盖的好处 场景 做法 本地开发 用 YAML 文件的默认配置 Docker 部署 用环境变量覆盖数据库地址、密码等 生产环境 用环境变量覆盖，敏感信息不写进代码 启动时指定配置文件 # 本地开发 cd backend \u0026amp;\u0026amp; CONFIG_PATH=configs/config.yaml go run ./cmd # Docker Compose本地 cd backend \u0026amp;\u0026amp; CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd API 进程和 Worker 进程为什么要分开 两个进程 cmd/ ├── main.go # API进程（处理HTTP请求） └── worker/ └── main.go # Worker进程（消费MQ消息） 各自做什么 API 进程 Worker 进程 职责 处理 HTTP 请求 消费 MQ 消息 入口 go run ./cmd go run ./cmd/worker 启动方式 接收 HTTP 请求 订阅 MQ 队列 为什么要分开 1. 职责不同\nAPI进程：用户发请求 → 处理 → 返回响应（要求快） Worker进程：从MQ取消息 → 更新DB、推送通知（可以慢） 2. 独立扩展\n场景：API扛不住了 没分开：只能整体加机器 分开后：只加API机器，Worker不动 场景：Worker扛不住了 分开后：只加Worker机器，API不动 3. 故障隔离\nWorker崩了：不影响API，用户还能正常访问 API崩了：不影响Worker，后台任务还在处理 4. 资源分配不同\nAPI进程：需要低延迟，分配更多CPU Worker进程：可以容忍高延迟，分配更多内存 Docker Compose 里的配置 backend: build: dockerfile: backend/Dockerfile target: api # 构建API镜像 worker: build: dockerfile: backend/Dockerfile target: worker # 构建Worker镜像 同一个 Dockerfile，用不同的 target 构建两个镜像。\npprof（性能分析） pprof 是什么 Go 内置的性能分析工具，帮你找出程序哪里慢、哪里吃内存。\n能分析什么 类型 作用 CPU 哪个函数最耗 CPU 内存 哪个函数最耗内存 goroutine 有没有 goroutine 泄漏 阻塞 哪里卡住了 配置 observability: pprof: enabled: true api_addr: localhost:6060 # API进程的pprof端口 worker_addr: localhost:6061 # Worker进程的pprof端口 启动后，访问 http://localhost:6060/debug/pprof/ 就能看到性能数据。\n常用命令 # CPU分析（30秒） go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # 内存分析 go tool pprof http://localhost:6060/debug/pprof/heap # goroutine分析 go tool pprof http://localhost:6060/debug/pprof/goroutine 场景举例 用户反馈：API响应慢 1. 用pprof抓CPU分析 2. 发现某个函数占了80%的CPU时间 3. 优化这个函数 4. 响应变快了 CI 流程（持续集成） CI 是什么 代码推送到 GitHub 后，自动运行检查，确保代码质量。\n这个项目的 CI # .github/workflows/ci.yml jobs: test: steps: - go vet ./... # 静态检查 - go test -race ./... # 运行测试，检测数据竞争 go vet 是什么 静态分析，找出语法错误和可疑代码，不需要运行程序。\n举例： - 变量声明了但没用 - 格式化字符串参数不对 - 死代码（永远不会执行的代码） go test -race 是什么 运行测试，同时检测数据竞争。\n什么是数据竞争 多个 goroutine 同时读写同一个变量，没有加锁。\n// 数据竞争的例子 var count int go func() { count++ }() // goroutine1写 go func() { count++ }() // goroutine2写 // 两个goroutine同时写count，结果不确定 -race 怎么检测 go test -race ./... 如果发现数据竞争： WARNING: DATA RACE Write by goroutine 1 Read by goroutine 2 ... FAIL CI 流程 开发者推送代码到GitHub ↓ GitHub Actions自动触发 ↓ go vet ./... → 静态检查 ↓ go test -race ./... → 运行测试 + 检测数据竞争 ↓ 全部通过 → 绿色 ✅ 有失败 → 红色 ❌，不允许合并 好处 没有 CI 有 CI 代码质量 靠人工 review 自动检查 数据竞争 上线后才发现 提交时就发现 合并代码 随便合并 检查通过才能合并 启动脚本（start.sh） 一键启动所有服务 ./start.sh 相关文件：~/dev/projects/feedsystem_video_go/start.sh\n脚本是什么语言写的 Bash（Shell 脚本），文件扩展名 .sh = Shell 脚本。\n#!/usr/bin/env bash # 声明用bash解释器 set -euo pipefail # 遇到错误立刻停止 Bash 是 Linux/macOS 终端的脚本语言，用来自动化命令行操作。\n脚本做的事 1. 启动Redis（如果没有运行） 2. 启动RabbitMQ（通过Docker Compose） 3. 启动Backend（go run ./cmd） 4. 启动Worker（go run ./cmd/worker） 5. 启动Frontend（npm run dev） 6. Ctrl+C停止所有服务 核心逻辑 # 启动Redis start_redis() { redis-server --bind 127.0.0.1 --port 6379 } # 启动RabbitMQ（通过Docker Compose） start_rabbitmq_compose() { docker compose up -d rabbitmq } # 启动Backend start_backend_bg() { cd backend \u0026amp;\u0026amp; go run ./cmd \u0026amp; } # 启动Worker start_worker_bg() { cd backend \u0026amp;\u0026amp; go run ./cmd/worker \u0026amp; } # 启动Frontend start_frontend_bg() { cd frontend \u0026amp;\u0026amp; npm run dev \u0026amp; } # Ctrl+C停止所有服务 trap cleanup INT TERM EXIT cleanup() { kill $BACKEND_PID kill $WORKER_PID kill $FRONTEND_PID docker compose stop } 环境变量控制 START_REDIS=1 # 是否启动Redis START_RABBITMQ=1 # 是否启动RabbitMQ START_BACKEND=1 # 是否启动Backend START_WORKER=1 # 是否启动Worker START_FRONTEND=1 # 是否启动Frontend 可以按需启动：\n# 只启动Backend和Worker START_FRONTEND=0 ./start.sh 面试要点总结 Q: Docker Compose 怎么保证启动顺序？ A: depends_on + healthcheck，服务健康了才启动下一个。\nQ: 配置优先级？ A: 环境变量 \u0026gt; .env 文件 \u0026gt; YAML 文件。\nQ: API 和 Worker 为什么要分开？ A: 职责不同、独立扩展、故障隔离、资源分配不同。\nQ: pprof 能分析什么？ A: CPU、内存、goroutine、阻塞。\nQ: CI 做了什么？ A: go vet 静态检查 + go test -race 检测数据竞争。\nQ: 什么是数据竞争？ A: 多个 goroutine 同时读写同一个变量，没有加锁。用 go test -race 检测。\nQ: 为什么要用 healthcheck？ A: 确保服务真正能接受连接，而不是只启动了容器。\n","date":"2026-07-29T00:00:00+08:00","image":"/MyBlog/p/go-feed-engineering/cover.svg","permalink":"/MyBlog/p/go-feed-engineering/","title":"Go 项目反推：Feed 流系统实战——工程化"},{"content":"承接 Go 项目反推：Feed 流系统实战——Redis 缓存设计，这一篇继续反推 feedsystem_video_go 的消息队列与异步架构，梳理 RabbitMQ 的消息路由、消费重试、降级策略、Outbox 模式和死信队列。\n什么是消息队列（MQ） 想象一个餐厅：\n没有 MQ：顾客点餐 → 厨师做完一个才能做下一个 → 顾客等着 有 MQ：顾客点餐 → 写在小票上放窗口 → 厨师按顺序做 → 做好了喊号 消息队列就是那个\u0026quot;窗口\u0026quot;，消息就是\u0026quot;小票\u0026quot;。\n为什么需要消息队列 用户点赞后要做的事：\n用户点赞 → 更新Like表 → 更新Video点赞数 → 更新热度 → 推送通知 → 返回成功 没有 MQ 的问题：\n用户等太久（每个操作都要时间） 任何一个失败，整个流程卡住 高并发时 DB 扛不住 有了 MQ：\n用户点赞 → 更新Like表 → 发一条\u0026#34;点赞消息\u0026#34;到MQ → 立刻返回成功 ↓ Worker从MQ取消息 → 更新点赞数 → 更新热度 → 推送通知 好处：\n用户不用等所有操作完成，体验快 点赞服务和热度计算服务解耦，改一个不影响另一个 高并发时消息排队，DB 不会被打垮 MQ 的三个核心概念 概念 比喻 作用 Exchange 邮局 接收消息，根据规则分发 Queue 邮箱 存储消息，等 Worker 来取 Routing Key 地址 消息的\u0026quot;目的地\u0026quot;，Exchange 根据它决定放哪个 Queue 流程：\n生产者（点赞服务） → 发消息到Exchange → Exchange根据Routing Key → 放到Queue → Worker从Queue取消息消费 三种 Exchange 类型 类型 规则 举例 Direct Routing Key 完全匹配 发到\u0026quot;like.like\u0026quot;，只有绑定了\u0026quot;like.like\u0026quot;的 Queue 才能收到 Topic 支持通配符（* 和 #） 发到\u0026quot;like.*\u0026quot;，能匹配\u0026quot;like.like\u0026quot;、\u0026ldquo;like.unlike\u0026rdquo; Fanout 广播，忽略 Routing Key 发到所有绑定的 Queue，每个 Queue 都能收到 这个项目用的是 Topic Exchange。\n项目里的 6 种 MQ Exchange Routing Key 用途 like.events like.like / like.unlike 点赞/取消点赞 comment.events comment.publish / comment.delete 评论/删除评论 social.events social.follow / social.unfollow 关注/取关 video.popularity.events video.popularity.update 热度更新 video.timeline.events video.timeline.publish 视频发布 dlx.events # 死信队列（重试/告警） 代码实现 MQ 相关文件 backend/internal/middleware/rabbitmq/ ├── rabbitMQ.go # MQ基础封装（连接、Channel、DeclareTopic、PublishJSON） ├── dlx.go # 死信队列声明和重试计数 ├── likeMQ.go # 点赞MQ ├── commentMQ.go # 评论MQ ├── socialMQ.go # 关注MQ ├── popularityMQ.go # 热度MQ └── timelineMQ.go # Feed流MQ（Outbox模式） Worker 相关文件 backend/internal/worker/ ├── likeworker.go # 点赞Worker ├── commentworker.go # 评论Worker ├── socialworker.go # 关注Worker ├── popularityworker.go # 热度Worker ├── outboxworker.go # Outbox定时任务 ├── notificationworker.go # 通知Worker └── ssehub.go # SSE推送Hub 消息生产（发消息） rabbitMQ.go - PublishJSON func PublishJSON(ctx context.Context, ch *amqp.Channel, exchange string, routingKey string, payload any) error { b, _ := json.Marshal(payload) // 第1步：把数据变成JSON return ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ ContentType: \u0026#34;application/json\u0026#34;, // 第2步：告诉MQ这是JSON格式 DeliveryMode: amqp.Persistent, // 第3步：消息持久化 Body: b, // 第4步：消息内容 }) } 逐行解释：\njson.Marshal(payload)：把 Go 结构体变成 JSON 字节 ch.PublishWithContext(...)：调用 MQ 客户端，发一条消息 exchange：发到哪个 Exchange（邮局） routingKey：消息地址（like.like） amqp.Persistent：消息存到磁盘，MQ 重启不丢 Body: b：消息内容 消息长什么样 { \u0026#34;video_id\u0026#34;: 123, \u0026#34;account_id\u0026#34;: 456, \u0026#34;event_type\u0026#34;: \u0026#34;like.like\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-07-28T14:30:00Z\u0026#34; } 就是一个 JSON，告诉 Worker\u0026quot;用户 456 给视频 123 点了赞\u0026quot;。\n消息消费（Worker 处理） likeworker.go - Run func (w *LikeWorker) Run(ctx context.Context) error { deliveries, err := w.ch.Consume(w.queue, \u0026#34;\u0026#34;, false, false, false, false, nil) for { select { case \u0026lt;-ctx.Done(): return ctx.Err() case d, ok := \u0026lt;-deliveries: if !ok { return errors.New(\u0026#34;deliveries channel closed\u0026#34;) } w.handleDelivery(ctx, d) } } } 逐行解释：\nw.ch.Consume(w.queue, ...)：从 Queue 订阅消息，返回一个 channel for { select { ... } }：循环读消息，有消息来就处理 w.handleDelivery(ctx, d)：调用处理函数，执行业务逻辑 handleDelivery - 重试逻辑 func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) { const maxRetries = 3 for i := 0; i \u0026lt;= maxRetries; i++ { if err := w.process(ctx, d.Body); err != nil { if i \u0026gt;= maxRetries { // 超过3次，放弃，Ack删除消息 log.Printf(\u0026#34;like worker: 重试 %d 次后仍失败, 丢弃\u0026#34;, maxRetries) _ = d.Ack(false) return } // 等一下再重试（指数退避） wait := time.Duration(1\u0026lt;\u0026lt;uint(i)) * time.Second // 1s, 2s, 4s log.Printf(\u0026#34;like worker: 处理失败, %v 后重试 (%d/%d)\u0026#34;, wait, i+1, maxRetries) time.Sleep(wait) continue } // 成功，Ack删除消息 _ = d.Ack(false) return } } 重试流程：\n第1次处理 → 失败 → 等1秒 第2次处理 → 失败 → 等2秒 第3次处理 → 失败 → 等4秒 第4次处理 → 失败 → 放弃，Ack删除消息 指数退避（1s → 2s → 4s） 1\u0026lt;\u0026lt;uint(i) 是位运算：\ni=0: 1\u0026laquo;0 = 1秒 i=1: 1\u0026laquo;1 = 2秒 i=2: 1\u0026laquo;2 = 4秒 为什么要等这么久？避免疯狂重试打垮服务器。\n为什么失败了还 Ack？ 不 Ack 的话，消息会一直留在 Queue 里，Worker 会一直重试，卡住后面的消息。Ack 后消息从 Queue 删除，避免阻塞。\nMQ 发布失败的降级策略 场景：用户点赞成功，DB 写入了，但发 MQ 消息失败了。\n如果直接忽略：热度不会更新，点赞数据和热度不一致。\nfallback 直写 func (s *LikeService) publishLikeEvent(ctx context.Context, videoID, accountID uint, routingKey string) { event := LikeEvent{VideoID: videoID, AccountID: accountID} err := rabbitmq.PublishJSON(ctx, s.ch, \u0026#34;like.events\u0026#34;, routingKey, event) if err != nil { // MQ发送失败，降级：直接更新热度 go s.popularityWorker.HandleLikeEvent(event) } } 意思：\nMQ 能用 → 走 MQ 异步处理 MQ 挂了 → 直接调 Worker 的处理函数，同步处理 为什么不用重试？如果 MQ 挂了，重试大概率还是失败，用户要等很久。直接降级：虽然绕过了 MQ，但保证功能正常，用户体验不受影响。\nOutbox 模式 什么是 Outbox 模式 发视频时用的模式。\n场景：用户发视频 → 更新 DB → 发 MQ 消息\n问题：如果 DB 更新成功，MQ 发送失败，视频发布了但 Feed 流里没有。\n为什么不能放在一个事务里？DB 和 MQ 是两个系统，没法用一个事务包起来。\nOutbox 的解法 把 MQ 消息先存到数据库里，用数据库事务保证一致性：\n数据库事务（保证原子性）： INSERT INTO videos (title, play_url, ...) VALUES (...) INSERT INTO outbox_msgs (video_id, status) VALUES (xxx, \u0026#39;pending\u0026#39;) COMMIT 两步在同一个 DB 事务里，要么都成功，要么都失败。\n然后用定时任务把消息发出去：\nOutboxPoller（每秒运行一次）： 查询：SELECT * FROM outbox_msgs WHERE status = \u0026#39;pending\u0026#39; 对于每条消息： 发MQ消息 更新：UPDATE outbox_msgs SET status = \u0026#39;sent\u0026#39; WHERE id = xxx OutboxMsg 表结构 type OutboxMsg struct { ID uint VideoID uint EventType string CreateTime time.Time Status string // \u0026#34;pending\u0026#34; 或 \u0026#34;sent\u0026#34; } 完整流程 00:00 用户发视频 → 写Video表 + 写OutboxMsg表（同一个事务，保证原子） 00:01 OutboxPoller扫到这条消息 → 发MQ消息成功 → 标记为sent 00:02 Worker收到MQ消息 → 更新Feed流 为什么这样能保证消息不丢？ 消息先存在 DB 里，和业务数据在同一个事务 定时任务保证最终会发出去 即使 MQ 暂时挂了，消息还在 DB 里，等 MQ 恢复了再发 回滚只发生在 DB 层面 数据库事务： INSERT INTO videos ... → 成功 INSERT INTO outbox_msgs ... → 失败 ROLLBACK → 两个都撤销 MQ 发送失败不回滚：\n数据库事务：COMMIT（消息已经存到outbox_msgs表了） OutboxPoller： 发MQ消息 → 失败（MQ挂了） → 不回滚！消息还在outbox_msgs表里 → 等MQ恢复了，下次扫描再发 为什么发视频用 Outbox，点赞不用？ 场景 方案 原因 点赞 直接发 MQ + fallback 点赞失败可以重试，用户能接受 发视频 Outbox 模式 视频必须进 Feed 流，不能丢 视频发布是关键业务，不能丢消息，所以用 Outbox 保证\u0026quot;至少发一次\u0026quot;。\n死信队列（DLX） 什么是死信队列 消息\u0026quot;死了\u0026quot;（消费失败），放到一个专门的队列里等处理。\n就像快递派送失败：\n正常流程：快递 → 送到你家 → 签收 派送失败：快递 → 送到你家 → 没人签收 → 退回快递站（死信队列） → 重新派送（重试） → 联系收件人（告警） 消息为什么会\u0026quot;死\u0026quot; 原因 举例 业务逻辑报错 热度计算除零了 消费超时 Worker 处理太久，MQ 等不及了 拒绝消息 代码里主动 reject 正常消费流程 Worker从Queue取消息 ↓ 处理成功 → msg.Ack() → MQ删除消息 消费失败了 Worker从Queue取消息 ↓ 处理失败 → msg.Nack() 或 超时 ↓ MQ把消息放到死信队列（DLX） ↓ 死信Worker处理： - 记录日志 - 重试（最多N次） - 超过重试次数 → 告警/人工处理 dlx.go - DeclareDLX const DLXExchange = \u0026#34;dlx.events\u0026#34; const MaxRetryCount = 3 func DeclareDLX(ch *amqp.Channel, queueName string) error { // 1. 声明死信Exchange ch.ExchangeDeclare(DLXExchange, \u0026#34;topic\u0026#34;, true, false, false, false, nil) // 2. 声明死信Queue（名字是 原Queue名 + \u0026#34;.dlx\u0026#34;） dlxQueue := queueName + \u0026#34;.dlx\u0026#34; ch.QueueDeclare(dlxQueue, true, false, false, false, nil) // 3. 绑定：# 匹配所有Routing Key ch.QueueBind(dlxQueue, \u0026#34;#\u0026#34;, DLXExchange, false, nil) } like.events 的死信队列叫 like.events.dlx comment.events 的死信队列叫 comment.events.dlx GetRetryCount - 获取重试次数 func GetRetryCount(d amqp.Delivery) int { deaths, ok := d.Headers[\u0026#34;x-death\u0026#34;].([]interface{}) if !ok || len(deaths) == 0 { return 0 } death, ok := deaths[0].(amqp.Table) if !ok { return 0 } count, ok := death[\u0026#34;count\u0026#34;].(int64) if !ok { return 0 } return int(count) } MQ 会自动在消息头里记录\u0026quot;这条消息死了几次\u0026quot;，从 x-death 里取出来。\n正常 Queue 声明时绑定 DLX func DeclareTopic(ch *amqp.Channel, exchange string, queue string, bindingKey string) error { // 声明Exchange ch.ExchangeDeclare(exchange, \u0026#34;topic\u0026#34;, true, false, false, false, nil) // 声明Queue，绑定死信Exchange q, err := ch.QueueDeclare(queue, true, false, false, false, amqp.Table{\u0026#34;x-dead-letter-exchange\u0026#34;: DLXExchange}) // 绑定：Queue订阅Exchange的消息 ch.QueueBind(q.Name, bindingKey, exchange, false, nil) } x-dead-letter-exchange：告诉 MQ，这个 Queue 的消息如果\u0026quot;死了\u0026quot;，发到 DLXExchange。\n完整流程举例 正常流程： 点赞 → like.events Queue → LikeWorker消费 → 成功 → Ack 失败流程： 点赞 → like.events Queue → LikeWorker消费 → 失败 → Nack ↓ like.events.dlx Queue（死信队列） ↓ 死信Worker处理（重试/告警） API 进程和 Worker 进程为什么要分开 cmd/ ├── main.go # API进程（处理HTTP请求） └── worker/ └── main.go # Worker进程（消费MQ消息） 分开的原因：\n职责不同：API 处理请求，Worker 处理后台任务 独立扩展：API 扛不住加 API 机器，Worker 扛不住加 Worker 机器 故障隔离：Worker 崩了不影响 API，API 崩了不影响 Worker 资源分配：API 需要低延迟，Worker 可以容忍高延迟 ch.Qos(50, 0, false) - 预取数量控制 ch.Qos(50, 0, false) 50：每次最多取 50 条消息 0：不限制消息大小 false：只对当前 Channel 生效 为什么要控制预取数量？\n太多：Worker 内存爆了 太少：Worker 闲着没事干 50：平衡点，Worker 处理完一批再取下一批 面试要点总结 Q: 为什么用 MQ？ A: 削峰（高并发时消息排队）、解耦（点赞服务和热度计算独立）、异步（用户不用等所有操作完成）。\nQ: MQ 发布失败了怎么办？ A: 降级策略，直接调 Worker 的处理函数同步处理。点赞用 fallback 直写，发视频用 Outbox 模式保证消息不丢。\nQ: Outbox 模式是什么？ A: 把 MQ 消息先存到数据库里，用 DB 事务保证一致性。定时任务扫描 outbox_msgs 表，发 MQ 消息，标记已发送。\nQ: 死信队列怎么用的？ A: 消息消费失败时，MQ 自动把消息放到死信队列。Worker 里有重试逻辑，最多重试 3 次，指数退避（1s→2s→4s），超过 3 次放弃。\nQ: API 进程和 Worker 进程为什么要分开？ A: 职责不同、独立扩展、故障隔离、资源分配不同。\nQ: 什么是指数退避？ A: 重试间隔按 2 的幂次增长（1s→2s→4s），避免疯狂重试打垮服务器。\nQ: 三种 Exchange 的区别？ A: Direct 精确匹配，Topic 支持通配符，Fanout 广播到所有 Queue。\nQ: 为什么点赞用 fallback，发视频用 Outbox？ A: 点赞失败用户能接受重试，视频发布是关键业务不能丢消息。Outbox 保证\u0026quot;至少发一次\u0026quot;。\n","date":"2026-07-29T00:00:00+08:00","image":"/MyBlog/p/go-feed-message-queue-async-architecture/cover.svg","permalink":"/MyBlog/p/go-feed-message-queue-async-architecture/","title":"Go 项目反推：Feed 流系统实战——消息队列与异步架构"},{"content":"承接 Go 项目反推：Feed 流系统实战——数据库与 GORM，这一篇继续反推 feedsystem_video_go 的 Redis 设计，梳理缓存读写、一致性与高并发防护，以及 Lua 限流和 ZSET 热榜的实现。\n项目里 Redis 的 7 种用途 # 用途 数据结构 Key模式 1 JWT Token 缓存 String account:\u0026lt;id\u0026gt; 2 Refresh Token 缓存 String account:\u0026lt;id\u0026gt;:refresh / refresh:\u0026lt;token\u0026gt; 3 视频详情缓存 String(JSON) video:detail:id=\u0026lt;id\u0026gt; 4 视频实体缓存 String(JSON) video:entity:\u0026lt;id\u0026gt; 5 热榜 ZSET hot:video:1m:\u0026lt;时间窗口\u0026gt; 6 分片上传会话 String(JSON) chunk_upload:\u0026lt;upload_id\u0026gt; 7 接口限流 String(计数器) ratelimit:\u0026lt;prefix\u0026gt;:\u0026lt;subject\u0026gt; 1. 缓存读取流程（video_service.go 的 GetDetail） func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) { cacheKey := vs.cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id) // 第一步：查缓存（50ms 超时） getCached := func() (*Video, bool) { opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() b, err := vs.cache.GetBytes(opCtx, cacheKey) if err != nil { return nil, false // 缓存没命中 } var cached Video if err := json.Unmarshal(b, \u0026amp;cached); err != nil { return nil, false // 数据损坏 } return \u0026amp;cached, true // 缓存命中 } // 尝试读缓存 if v, ok := getCached(); ok { return v, nil // 命中，直接返回 } // 缓存没命中，查数据库... } 为什么缓存读取要设 50ms 超时？ 50ms 超时不是判断\u0026quot;没命中\u0026quot;，而是防止 Redis 卡住的降级策略：\n缓存没命中：Redis 快速返回\u0026quot;key 不存在\u0026quot;，几毫秒就完成 Redis 卡住：网络抖动、Redis 内部阻塞、连接池耗尽，可能卡几秒 没有超时：Redis 卡住 → 请求一直等着 → 用户体验很差 有 50ms 超时：Redis 超过 50ms 没响应 → 放弃缓存 → 直接查数据库\n为什么不设更短的超时，比如 5ms？ Redis 正常操作本身需要几毫秒，设 5ms 正常情况都可能超时，缓存形同虚设。50ms 是平衡点：覆盖正常操作 + 轻微网络波动，同时不会让用户等太久。\n2. 缓存写入 setCached := func(video *Video) { b, err := json.Marshal(video) if err != nil { return } opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() _ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL) } 为什么缓存要设过期时间（TTL）？ TTL = Time To Live，缓存的\u0026quot;保质期\u0026quot;。\n主要原因：数据一致性\n09:00 缓存存了视频标题\u0026#34;今天天气真好\u0026#34; 09:05 作者把标题改成\u0026#34;今天下雨了\u0026#34; → 数据库更新了，但缓存还是旧的 09:06 用户查这个视频 → 从缓存读到\u0026#34;今天天气真好\u0026#34; → 看到的是过期数据 没有 TTL：缓存永远是旧数据，用户看到的和数据库不一致 有了 TTL（比如 10 分钟）：最多 10 分钟后缓存自动过期，下次请求加载最新数据\n这是用时间换一致性：允许短暂不一致，但最终会一致。\n次要原因：缓存空间有限\n缓存满了需要淘汰旧数据，TTL 帮助自动清理。\n3. 缓存删除（主动失效） 为什么不等 TTL 过期，要主动删缓存？ 看 video_service.go 删视频时：\nfunc (vs *VideoService) DeleteVideo(ctx context.Context, id uint) error { // 先删数据库 if err := vs.repo.DeleteVideo(ctx, id); err != nil { return err } // 再删缓存 if vs.cache != nil { cacheKey := vs.cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id) _ = vs.cache.Del(context.Background(), cacheKey) } return nil } DB 更新后立刻删缓存，下次请求来了缓存没命中，从数据库加载最新数据。\n4. 缓存和 DB 的一致性策略 两种方案对比 方案 做法 风险 先删缓存，再更新 DB 删缓存 → 写 DB 删完缓存、还没写 DB 时，另一个请求读 DB 旧数据写入缓存 先更新 DB，再删缓存 写 DB → 删缓存 写完 DB、还没删缓存时，另一个请求读到旧缓存 为什么\u0026quot;先更新 DB 再删缓存\u0026quot;更安全？ 方案 1：先删缓存，再更新 DB\n00:00 请求A：删缓存 00:01 请求B：查视频 → 缓存没命中 → 从DB读到旧数据 → 写入缓存 00:02 请求A：更新DB为新数据 结果：DB是新数据，缓存里是旧数据（脏数据） 发生概率不低，因为\u0026quot;删缓存\u0026quot;到\u0026quot;更新 DB\u0026quot;之间可能有几百毫秒（DB 写入比 Redis 慢）。\n方案 2：先更新 DB，再删缓存\n00:00 请求A：更新DB为新数据 00:01 请求B：查视频 → 从缓存读到旧数据（概率小） 00:02 请求A：删缓存 结果：即使请求B读到旧缓存，00:02缓存就被删了 出问题窗口极小：必须在\u0026quot;更新 DB\u0026quot;和\u0026quot;删缓存\u0026quot;之间，恰好有另一个请求来读。\n共同风险点：删缓存失败了怎么办？ 00:00 更新DB成功 → DB是新数据 00:01 删缓存失败（网络抖动、Redis挂了） → 缓存还是旧数据 解决方案：靠 TTL 兜底\n代码里删缓存都用了 _ = 忽略错误：\n_ = vs.cache.Del(context.Background(), cacheKey) 即使删缓存失败，最多等 TTL 过期（比如 10 分钟），缓存自动失效，下次请求从 DB 加载新数据。\n这是最终一致性：允许短暂不一致，但最终会一致。\n5. 缓存穿透/击穿/雪崩 三个概念的区别 问题 现象 危害 缓存穿透 查询不存在的数据，缓存永远没命中 每次请求都打到 DB 缓存击穿 热点 key 过期，大量请求同时打到 DB DB 瞬间压力暴增 缓存雪崩 大量 key 同时过期 DB 瞬间压力暴增 缓存击穿的解法（这个项目用的） 场景：一个热门视频的缓存过期了，100 个用户同时请求\n// 缓存没命中，尝试获取分布式锁 lockKey := \u0026#34;lock:\u0026#34; + cacheKey token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second) if lockErr == nil \u0026amp;\u0026amp; locked { // 拿到锁：去查DB，回填缓存 defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }() video, err := vs.repo.GetByID(ctx, id) if err != nil { return nil, err } setCached(video) return video, nil } // 没拿到锁：等待别人回填缓存（最多100ms） for i := 0; i \u0026lt; 5; i++ { time.Sleep(20 * time.Millisecond) if v, ok := getCached(); ok { return v, nil // 别人已经回填了，直接用 } } // 降级：直接查DB video, err := vs.repo.GetByID(ctx, id) 核心逻辑：让一个请求干活，其他请求等着用结果\n❌ 没有锁的击穿场景： 100个请求同时来 → 缓存没命中 → 100个都去查DB → DB压力x100 ✅ 有锁的保护： 100个请求同时来 → 缓存没命中 ↓ 请求1：拿到锁 → 查DB → 写缓存 → 返回 请求2-100：没拿到锁 → 等20ms → 查缓存 → 命中 → 返回 时序示例（DB 查询 15ms）：\n00:00 请求1拿到锁，开始查DB（耗时15ms） 请求2-100没拿到锁，开始sleep 20ms 00:15 请求1查完DB，写入缓存 00:20 请求2-100醒来，查缓存 → 命中！ 降级策略（DB 查询很慢时）：\n请求 2-100 最多等 100ms（5次x20ms），如果请求 1 还没完成，降级去查 DB。\n但这种情况很少见，DB 查询通常几毫秒到几十毫秒。\n缓存穿透的解法（这个项目没做） 缓存穿透：查询不存在的数据，比如 GET /video/999999999，数据库里没有，每次请求都打到 DB。\n方案 做法 缓存空值 查 DB 没结果，也写入缓存（value=\u0026quot;\u0026quot;），设短 TTL 布隆过滤器 在缓存前加一层，快速判断 key 是否存在 项目为什么没做：视频 ID 通过 Feed 流返回，不存在\u0026quot;查不存在 ID\u0026quot;的正常场景。接口有限流保护，攻击成本高。\n缓存雪崩的解法（这个项目没做） 缓存雪崩：大量 key 同时过期，DB 瞬间压力暴增。\n方案 做法 随机 TTL 给缓存 TTL 加随机值，避免同时过期 多级缓存 L1 本地缓存 + L2 Redis 缓存 永不过期 + 异步更新 缓存不设 TTL，由后台任务定期更新 项目用的是固定 TTL，没做随机化，业务量可控。\n6. 限流中间件（ratelimit.go） 限流是什么？ 防止用户发太多请求打垮服务器。像银行取号机：每分钟最多服务 100 个人，第 101 个人来了告诉他\u0026quot;请稍后再来\u0026quot;。\n限流中间件是什么？ Gin 中间件，所有请求都要经过它：\n用户请求 → 限流中间件检查 → 通过 → handler处理 → 返回 ↓ 超限 → 直接返回429 \u0026#34;too many requests\u0026#34; 限流实现原理 func Limit(cache *rediscache.Client, keyPrefix string, maxRequests int64, window time.Duration, keyFunc KeyFunc) gin.HandlerFunc { return func(c *gin.Context) { subject, ok := keyFunc(c) key := buildKey(keyPrefix, subject) count, err := cache.IncrementWithExpire(c.Request.Context(), key, window) if err != nil { c.Next() return } if count \u0026gt; maxRequests { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{\u0026#34;error\u0026#34;: \u0026#34;too many requests\u0026#34;}) return } c.Next() } } IncrementWithExpire 的 Lua 脚本 local count = redis.call(\u0026#34;INCR\u0026#34;, KEYS[1]) if count == 1 then redis.call(\u0026#34;PEXPIRE\u0026#34;, KEYS[1], ARGV[1]) end return count 限流流程（1 分钟内最多 100 次）：\n00:00.000 请求1来 → INCR → count=1 因为count==1，是第一个请求 → PEXPIRE设60000毫秒（60秒）过期 00:00.001 请求2来 → INCR → count=2 count!=1，不设过期（沿用之前的） ... 00:00.500 请求100来 → INCR → count=100 → 通过（100 \u0026lt;= 100） 00:00.600 请求101来 → INCR → count=101 → 拒绝！（101 \u0026gt; 100） → 返回429 \u0026#34;too many requests\u0026#34; 01:00.000 key自动过期，Redis删除这个key 计数器归零 01:00.001 请求来 → INCR → count=1（重新开始计数） → PEXPIRE重新设60秒 → 通过 为什么用 Lua 脚本？ 原子执行 = 两条命令之间不会有其他命令插队\n没有 Lua 脚本（可能被插队）：\n00:00 你的程序：INCR key → count=1 00:01 另一个程序：INCR key → count=2 00:02 你的程序：EXPIRE key 60秒 00:03 另一个程序：EXPIRE key 60秒 你的 INCR 和 EXPIRE 之间，被别人插队了。\n有 Lua 脚本（不会插队）：\n00:00 你的程序：开始执行Lua脚本 INCR key → count=1 EXPIRE key 60秒 00:01 Lua脚本执行完，其他程序才能执行 注意：原子执行 ≠ 回滚，原子执行 = 不插队\n如果 INCR 成功了，EXPIRE 失败了，INCR 的结果不会撤销。\n为什么需要原子执行？ 如果分开写：\ncount = redis.INCR(key) // count=1 // 这里程序崩了，EXPIRE没执行 redis.EXPIRE(key, 60秒) // 没执行到 结果：key 永远不过期，计数永远不会归零，限流永久生效。\nLua 脚本保证 INCR 和 EXPIRE 一起执行，不会出现\u0026quot;只执行了一半\u0026quot;的情况。\nEXPIRE 和 PEXPIRE EXPIRE：单位是秒 PEXPIRE：单位是毫秒 限流的 key 设计 func buildKey(keyPrefix, subject string) string { return fmt.Sprintf(\u0026#34;feedsystem:ratelimit:%s:%s\u0026#34;, keyPrefix, strings.TrimSpace(subject)) } 两种维度：\nKeyByIP：按 IP 限流，防止单 IP 攻击 KeyByAccount：按用户 ID 限流，防止单用户刷接口 7. ZSET 热榜（popularity_cache.go） ZSET 是什么？ Redis 的有序集合，每个元素有一个分数，自动按分数排序。\nZSET \u0026#34;热榜\u0026#34; ┌─────────┬─────────┐ │ 元素 │ 分数 │ ├─────────┼─────────┤ │ video_5 │ 1000 │ ← 最热 │ video_2 │ 800 │ │ video_8 │ 500 │ │ video_1 │ 100 │ ← 最冷 └─────────┴─────────┘ 热榜更新代码 func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) { // 删掉旧缓存 _ = cache.Del(context.Background(), cache.Key(\u0026#34;video:detail:id=%d\u0026#34;, id)) _ = cache.Del(context.Background(), cache.Key(\u0026#34;video:entity:%d\u0026#34;, id)) // 写入热榜 now := time.Now().UTC().Truncate(time.Minute) // 当前时间，精确到分钟 windowKey := cache.Key(\u0026#34;hot:video:1m:%s\u0026#34;, now.Format(\u0026#34;200601021504\u0026#34;)) // 热榜key member := strconv.FormatUint(uint64(id), 10) // 视频ID _ = cache.ZincrBy(opCtx, windowKey, member, float64(change)) // 热度+change _ = cache.Expire(opCtx, windowKey, 2*time.Hour) // 2小时后过期 } 滑动窗口设计 热榜 key 是 hot:video:1m:202607281430，意思是\u0026quot;2026 年 7 月 28 日 14:30 这一分钟的热榜\u0026quot;。\n00:00 video_5被点赞 → hot:video:1m:202607281430 → video_5分数+1 00:30 video_5又被点赞 → hot:video:1m:202607281430 → video_5分数+1 01:00 新的一分钟 → hot:video:1m:202607281431 每分钟一个 ZSET，2 小时后自动过期。\n为什么要按分钟分窗口？ 如果用一个大的 ZSET（不分窗口）：\n所有点赞都写到同一个key \u0026#34;hot:video\u0026#34; 1月1日：video_5被点赞1000次，分数=1000，排第一 1月2日：video_8被点赞500次，分数=500 1月3日：video_5被点赞0次，但分数还是1000，还是排第一 问题：旧视频的热度永远不衰减，新视频永远追不上。\n按分钟分窗口：\n每分钟一个ZSET： hot:video:1m:202607281430 → 14:30这一分钟的热度 hot:video:1m:202607281431 → 14:31这一分钟的热度 hot:video:1m:202607281432 → 14:32这一分钟的热度 查热榜时，把最近几个窗口合并：\n14:35查热榜： → 合并 14:30、14:31、14:32、14:33、14:34、14:35 这6个窗口 → 算总分，排序 好处：\n14:30 的热度，到 14:35 时已经衰减了（只有 1/6 权重） 14:35 新产生的热度立刻反映 热榜是\u0026quot;最近几分钟\u0026quot;的热度，不是\u0026quot;历史累计\u0026quot; ZSET 常用操作 // 分数增加 cache.ZincrBy(ctx, key, member, score) // 按分数倒序取 top N cache.ZRevRange(ctx, key, 0, 99) // 取前100名 // 合并多个ZSET cache.ZUnionStore(ctx, dst, keys, \u0026#34;SUM\u0026#34;) // 设置过期时间 cache.Expire(ctx, key, 2*time.Hour) 面试要点总结 Q: 缓存和 DB 的一致性怎么保证？ A: 先更新 DB，再删缓存。删缓存失败靠 TTL 兜底，最终一致性。\nQ: 缓存穿透/击穿/雪崩的区别和解法？ A: 穿透是查不存在的数据（缓存空值/布隆过滤器），击穿是热点 key 过期（分布式锁 + 等待重试），雪崩是大量 key 同时过期（随机 TTL）。\nQ: 限流怎么实现的？ A: Redis 计数器，Lua 脚本原子执行 INCR + PEXPIRE，超限返回 429。\nQ: 为什么用 Lua 脚本？ A: 保证 INCR 和 EXPIRE 原子执行，不会被其他命令插队，避免\u0026quot;只执行了一半\u0026quot;的问题。\nQ: 热榜怎么实现的？ A: ZSET 按分钟分窗口，点赞时 ZincrBy 加分数，查热榜时合并最近几个窗口算总分。分窗口实现热度自动衰减。\n","date":"2026-07-28T00:00:00+08:00","image":"/MyBlog/p/go-feed-redis-cache-design/cover.svg","permalink":"/MyBlog/p/go-feed-redis-cache-design/","title":"Go 项目反推：Feed 流系统实战——Redis 缓存设计"},{"content":"承接 Go 项目反推：Feed 流系统实战——认证体系，这一篇继续反推 feedsystem_video_go 的数据库代码，理解表结构、索引设计和 GORM 的常见用法。\n理解所有表结构、索引设计、GORM 用法。\n1. entity.go 的两种结构体 entity.go 里有两种东西：\n1. 存数据库的（有 gorm 标签） → Social struct、Like struct... 2. 不存数据库的（没有 gorm 标签）→ FeedVideoItem、Request、Response... gorm:\u0026quot;xxx\u0026quot; 标签 = 给 GORM 看的指令（怎么存数据库） json:\u0026quot;xxx\u0026quot; 标签 = 给 JSON 看的指令（怎么显示给客户端） 两者互不影响，各管各的 2. gorm 标签汇总 标签 含义 示例 gorm:\u0026quot;primaryKey\u0026quot; 主键（唯一标识，类似身份证号） ID uint gorm:\u0026quot;primaryKey\u0026quot; gorm:\u0026quot;unique\u0026quot; 唯一（不能重复） Username string gorm:\u0026quot;unique\u0026quot; gorm:\u0026quot;index\u0026quot; 普通索引（加快查询，可以重复） VideoID uint gorm:\u0026quot;index\u0026quot; gorm:\u0026quot;uniqueIndex:idx_name\u0026quot; 唯一索引（不能重复） VideoID uint gorm:\u0026quot;uniqueIndex:idx_like_video_account\u0026quot; gorm:\u0026quot;uniqueIndex:idx_name;not null\u0026quot; 联合唯一索引（多个字段组合唯一） 见 Like 表 gorm:\u0026quot;index:idx_name,priority:1,sort:desc\u0026quot; 索引排序优先级 见 Video 表 gorm:\u0026quot;not null\u0026quot; 不能为空 Title string gorm:\u0026quot;not null\u0026quot; gorm:\u0026quot;default:false\u0026quot; 默认值 IsRead bool gorm:\u0026quot;default:false\u0026quot; gorm:\u0026quot;autoCreateTime\u0026quot; 自动填创建时间 CreateTime time.Time gorm:\u0026quot;autoCreateTime\u0026quot; gorm:\u0026quot;type:varchar(255)\u0026quot; 字符串长度限制 Title string gorm:\u0026quot;type:varchar(255)\u0026quot; gorm:\u0026quot;type:text\u0026quot; 长文本（无固定限制） Content string gorm:\u0026quot;type:text\u0026quot; gorm:\u0026quot;column:likes_count\u0026quot; 指定数据库列名 Go 驼峰 → DB 下划线 gorm:\u0026quot;-\u0026quot; 不存数据库 临时字段 3. json 标签 标签 含义 json:\u0026quot;id\u0026quot; JSON 里显示为 \u0026ldquo;id\u0026rdquo; json:\u0026quot;-\u0026quot; JSON 里不显示（密码、Token 等敏感信息） json:\u0026quot;description,omitempty\u0026quot; 空值时不显示这个字段 4. 表结构一览 Account 表（用户表） ID → 主键 Username → 唯一（不能重复注册） Password → 不显示在 JSON 里 Token → 不显示在 JSON 里 RefreshToken → 不显示在 JSON 里 AvatarURL → 头像地址 Bio → 个人简介 Video 表（视频表） ID → 主键 AuthorID → 外键（关联 Account 表，存的是用户ID） Username → 作者名（冗余存储，方便查询） Title → 标题 Description → 描述 PlayURL → 播放地址 CoverURL → 封面地址 CreateTime → 自动填创建时间 LikesCount → 点赞数（统计字段） Popularity → 热度值（统计字段） Video 表的三个索引：\n索引名 排序规则 用途 idx_videos_create_time CreateTime DESC 最新视频 idx_videos_likes_count_id LikesCount DESC 最热视频（按点赞） idx_videos_popularity_time_id Popularity DESC, CreateTime DESC 综合热度 priority = 优先级，数字越小越优先。\nLike 表（点赞表） ID → 主键 VideoID → 外键 + 联合唯一索引 AccountID → 外键 + 联合唯一索引 联合唯一索引：VideoID + AccountID 的组合不能重复\n张三给视频 A 点赞 ✓ 张三再给视频 A 点赞 ✗（违反唯一索引） 李四给视频 A 点赞 ✓（不同人） 张三给视频 B 点赞 ✓（不同视频） 为什么需要 Like 表 + Video 表的 LikesCount？\nLike 表 → 记录\u0026#34;关系\u0026#34;（谁点了谁） LikesCount → 记录\u0026#34;总数\u0026#34;（点赞数） 两者配合：Like 管关系，LikesCount 管统计 Comment 表（评论表） ID → 主键 Username → 普通索引 VideoID → 外键 + 普通索引 AuthorID → 外键 + 普通索引 Content → 长文本（type:text） CreatedAt → 自动填创建时间 index vs uniqueIndex：\nindex = 普通索引（可以重复，一个视频可以有很多评论） uniqueIndex = 唯一索引（不能重复） Social 表（关注表） ID → 主键 FollowerID → 外键 + 联合唯一索引（谁在关注） VloggerID → 外键 + 联合唯一索引（关注谁） 联合唯一索引：一个人不能关注同一个人两次。\nTag 表 + VideoTag 表（标签表） Tag 表： ID → 主键 Name → 唯一索引（标签名不能重复） VideoTag 表（中间表）： ID → 主键 VideoID → 外键 + 普通索引 TagID → 外键 + 普通索引 多对多关系：一个视频可以有多个标签，一个标签可以属于多个视频，需要中间表 VideoTag 来记录关系。\nMessage 表（私信表） ID → 主键 FromID → 外键 + 普通索引（谁发的） ToID → 外键 + 普通索引（发给谁） Content → 长文本 IsRead → 是否已读（默认 false） CreatedAt → 自动填创建时间 ChunkUploadSession（分片上传） 不是 MySQL 表，存 Redis 的。\nUploadID → 上传会话ID AccountID → 谁在上传 UploadedBits → []bool 位图，记录每片上传状态 FeedVideoItem（Feed 流响应格式） 不是数据库表，是 API 响应格式。\nAuthor → FeedAuthor（包含 ID + Username，不是外键） IsLiked → 动态计算（查 Like 表），不存在数据库里 5. db.go（数据库初始化） 三个函数：\nNewDB() → 连接数据库（DSN 格式：用户名:密码@tcp(地址:端口)/数据库名） AutoMigrate() → 根据 struct 自动建表/更新表 CloseDB() → 关闭数据库连接 AutoMigrate 的好处：\n手动建表：写 SQL，容易忘、容易错 AutoMigrate：改 struct，重启程序，自动更新表 6. repo.go（数据库操作） 每个模块都有自己的 repo.go，包含增删改查操作。\n基础套路 // 创建 ar.db.WithContext(ctx).Create(\u0026amp;data) // 查询（根据ID） ar.db.WithContext(ctx).First(\u0026amp;result, id) // 查询（根据条件） ar.db.WithContext(ctx).Where(\u0026#34;username = ?\u0026#34;, name).First(\u0026amp;result) // 更新（单字段） ar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;username\u0026#34;, newName) // 更新（多字段） ar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Updates(map[string]interface{}{\u0026#34;token\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;refresh_token\u0026#34;: \u0026#34;\u0026#34;}) // 删除 ar.db.WithContext(ctx).Delete(\u0026amp;Like{}).Where(\u0026#34;video_id = ? AND account_id = ?\u0026#34;, vID, aID) db.WithContext(ctx) 的作用 把 context 传进去，支持超时取消。\n事务（db.Transaction） ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 操作1 tx.Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;username\u0026#34;, newName) // 操作2 tx.Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Update(\u0026#34;token\u0026#34;, token) return nil }) 事务 = 要么全做，要么全不做。\n类比：银行转账，A 扣钱与 B 加钱必须同时成功或同时失败。\n原子操作（gorm.Expr） // 错误做法：先读再写，并发会出错 likes_count = 100 用户A读到100，用户B读到100 用户A写入101，用户B写入101 结果：只加了1 ❌ // 正确做法：让数据库直接加 gorm.Expr(\u0026#34;likes_count + ?\u0026#34;, 1) 数据库排队执行：100+1=101, 101+1=102 结果：正确加了2 ✓ GREATEST(likes_count + ?, 0)：防止点赞数变成负数。\nLikeIgnoreDuplicate（忽略重复点赞） err = r.db.WithContext(ctx).Create(like).Error if err == nil { return true, nil // 创建成功 } // 如果是\u0026#34;重复主键\u0026#34;错误（1062），忽略 var mysqlErr *mysql.MySQLError if errors.As(err, \u0026amp;mysqlErr) \u0026amp;\u0026amp; mysqlErr.Number == 1062 { return false, nil // 已经点过赞了，不报错 } 为什么需要这个？\n联合唯一索引：保证数据正确（一人只能点一次） LikeIgnoreDuplicate：保证用户体验（重复点赞不报错） 两个配合：数据对 + 体验好 gorm.ErrRecordNotFound（判断记录不存在） if err == gorm.ErrRecordNotFound { return false, nil // 记录不存在 } 关联查询（Joins） // 查\u0026#34;张三点赞过的所有视频\u0026#34; r.db.WithContext(ctx). Model(\u0026amp;Video{}). Joins(\u0026#34;JOIN likes ON likes.video_id = videos.id\u0026#34;). Where(\u0026#34;likes.account_id = ?\u0026#34;, accountID). Find(\u0026amp;videos) 分步查询（GetAllFollowers） -- 第1步：从 Social 表查出 follower_id 列表 SELECT follower_id FROM socials WHERE vlogger_id = 1; -- 得到 [2, 5, 8] -- 第2步：用 ID 列表去 Account 表查用户信息 SELECT * FROM accounts WHERE id IN (2, 5, 8); 为什么分两步？\nSocial 表只存 ID（避免数据冗余），Account 表存用户信息。用户改名只改 Account 表，Social 表不用动。\n7. 数据库规范化 同一份数据只存一个地方，其他地方只存 ID（外键）。\n反范例：Social 表存用户名 用户改名 → 要更新 Account 表 + Social 表 + 其他表... 牵一发动全身 正范例：Social 表只存 ID 用户改名 → 只改 Account 表 需要名字时，拿着 ID 去 Account 表查 8. 面试要点 1. Video 表为什么有三个索引？ 因为有三种查询需求：\nidx_videos_create_time → 按时间排序（查最新视频） idx_videos_likes_count_id → 按点赞数排序（查最热视频） idx_videos_popularity_time_id → 按热度 + 时间排序（综合排名） 每种查询方式都需要自己的“目录”（索引），没有索引就要全表扫描，很慢。\n2. 点赞表为什么使用联合唯一索引？ 点赞表用 uniqueIndex:idx_like_video_account，保证 VideoID + AccountID 的组合唯一。一个人只能给一个视频点一次赞，如果重复点赞会违反唯一索引报错。配合 LikeIgnoreDuplicate 函数，重复点赞时静默忽略，不向用户报错。\n3. gorm.Expr 是怎么解决并发问题的？ 错误做法：先读 likes_count=100，再写 100+1=101。两个用户同时读到 100，都写入 101，结果只加了 1。\n正确做法：用 gorm.Expr(\u0026quot;likes_count + ?\u0026quot;, 1)，让数据库直接执行 likes_count = likes_count + 1，数据库保证原子更新，结果正确。GREATEST(likes_count + ?, 0) 可以防止点赞数变成负数。\n4. 改名 + 更新 Token 为什么要用事务？ 因为改名后 Token 里的 Username 就过期了，必须同时更新。事务保证“要么全做，要么全不做”：改名成功但更新 Token 失败时回滚，改名也不生效。\n5. 为什么 Social 表只存 ID，不存用户名？ 如果存用户名，用户改名时要更新 Account 表、Social 表和其他表，牵一发动全身。只存 ID 时，用户改名只改 Account 表，Social 表不用动；需要名字时拿着 ID 去 Account 表查询。\n6. Tag 和 Video 为什么需要中间表？ 一个视频可以有多个标签，一个标签也可以属于多个视频，这是多对多关系。一个外键字段只能存一个值，因此需要中间表 VideoTag 存储 VideoID + TagID，记录“哪个视频有哪些标签”。\n","date":"2026-07-27T00:00:00+08:00","image":"/MyBlog/p/go-feed-database-gorm/cover.svg","permalink":"/MyBlog/p/go-feed-database-gorm/","title":"Go 项目反推：Feed 流系统实战——数据库与 GORM"},{"content":"1. 学习目标 这组实验主要解决以下问题：\n从零实现线性回归的代价函数和梯度； 使用批量梯度下降训练模型； 判断梯度下降是否正在收敛； 选择合适的学习率； 使用 Z-score 标准化加快收敛； 通过特征工程实现多项式回归； 使用 Scikit-Learn 完成标准化、训练与预测。 2. 梯度下降的核心流程 线性回归模型：\n$$f_{\\mathbf{w},b}(\\mathbf{x}) = \\mathbf{w}\\cdot\\mathbf{x}+b$$整个训练过程可以概括为：\n输入训练数据 X、y ↓ 使用当前 w、b 计算预测 ↓ 预测值减去真实值得到误差 ↓ 根据全部误差计算梯度 ↓ 沿梯度反方向更新 w、b ↓ 重复，直到代价不再明显下降 参数更新规则：\n$$w_j \\leftarrow w_j-\\alpha\\frac{\\partial J}{\\partial w_j}$$$$b \\leftarrow b-\\alpha\\frac{\\partial J}{\\partial b}$$其中：\n$\\alpha$：学习率，决定每一步走多远； 梯度：代价函数上升最快的方向； 减去梯度：向代价下降的方向移动。 3. 实践一：从零实现一元线性回归 实验使用城市人口预测餐厅利润：\nx_train：城市人口，单位为 10,000 人； y_train：餐厅月利润，单位为 10,000 美元； 数据集共有 97 个样本。 一元线性回归模型：\n$$f_{w,b}(x^{(i)})=wx^{(i)}+b$$3.1 实现代价函数 平方误差代价函数：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)^2$$代码：\ndef compute_cost(x, y, w, b): m = x.shape[0] total_cost = 0.0 for i in range(m): prediction = w * x[i] + b error = prediction - y[i] total_cost += error ** 2 return total_cost / (2 * m) 这里除以 $2m$：\n除以 $m$：计算所有样本的平均损失； 除以 2：求导后平方项产生的 2 可以抵消，使梯度公式更简洁。 3.2 实现梯度 梯度公式：\n$$\\frac{\\partial J}{\\partial w} = \\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)x^{(i)}$$$$\\frac{\\partial J}{\\partial b} = \\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{w,b}(x^{(i)})-y^{(i)} \\right)$$代码：\ndef compute_gradient(x, y, w, b): m = x.shape[0] dj_dw = 0.0 dj_db = 0.0 for i in range(m): prediction = w * x[i] + b error = prediction - y[i] dj_dw += error * x[i] dj_db += error dj_dw /= m dj_db /= m return dj_dw, dj_db dj_dw 多乘了一个 x[i]，因为 $w$ 是和输入 $x$ 相乘的参数；而 $b$ 只是直接相加，因此 dj_db 不需要乘 x[i]。\n3.3 批量梯度下降 import copy import math def gradient_descent( x, y, w_in, b_in, cost_function, gradient_function, alpha, num_iters ): w = copy.deepcopy(w_in) b = b_in J_history = [] for i in range(num_iters): dj_dw, dj_db = gradient_function(x, y, w, b) # 必须使用同一次迭代计算出的梯度同步更新 w = w - alpha * dj_dw b = b - alpha * dj_db if i \u0026lt; 100_000: J_history.append(cost_function(x, y, w, b)) if i % math.ceil(num_iters / 10) == 0: print( f\u0026#34;Iteration {i:4d}: \u0026#34; f\u0026#34;Cost {J_history[-1]:8.2f}\u0026#34; ) return w, b, J_history 实验参数：\ninitial_w = 0.0 initial_b = 0.0 iterations = 1500 alpha = 0.01 w, b, J_history = gradient_descent( x_train, y_train, initial_w, initial_b, compute_cost, compute_gradient, alpha, iterations ) 实验结果大约为：\nw = 1.1664 b = -3.6303 预测利润：\nprofit_35k = (3.5 * w + b) * 10_000 profit_70k = (7.0 * w + b) * 10_000 print(profit_35k) # 约 4519.77 美元 print(profit_70k) # 约 45342.45 美元 这里的\u0026quot;批量\u0026quot;表示：\n每轮使用全部 $m$ 个训练样本计算平均梯度，然后更新一次参数。\n4. 多变量梯度下降 假设训练数据形状为：\nX：(m, n) y：(m,) w：(n,) b：标量 其中：\n$m$：训练样本数量； $n$：每个样本的特征数量。 预测：\ny_hat = X @ w + b 误差：\nerrors = y_hat - y 向量化代价：\ndef compute_cost(X, y, w, b): errors = X @ w + b - y return np.sum(errors ** 2) / (2 * X.shape[0]) 向量化梯度：\ndef compute_gradient(X, y, w, b): m = X.shape[0] errors = X @ w + b - y dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m return dj_dw, dj_db 形状变化：\nX.T ：(n, m) errors ：(m,) X.T @ errors ：(n,) dj_dw ：(n,) dj_db ：标量 最终得到的 dj_dw 恰好包含 $n$ 个梯度，对应 $w$ 中的 $n$ 个参数。\n5. 学习率实验 在未缩放的房价数据中，不同学习率会产生不同表现。\n$\\alpha=9.9\\times10^{-7}$ 学习率过大：\n参数更新越过最低点； 代价不断增加； 模型发散。 $\\alpha=9\\times10^{-7}$ 学习率稍小：\n代价持续下降； 参数仍会在最低点两侧震荡； 最终能够收敛。 $\\alpha=1\\times10^{-7}$ 学习率更小：\n参数较平稳地靠近最低点； 基本没有明显震荡； 但收敛速度更慢。 因此：\n学习率太大 → 震荡或发散 学习率太小 → 稳定但很慢 学习率合适 → 快速且稳定下降 6. 为什么需要特征缩放？ 房价数据包含以下特征：\n面积：几百到几千 卧室：0 到 5 楼层：1 到 3 房龄：数十年 假设：\n$$f(\\mathbf{x})=w_1x_1+w_2x_2+b$$如果 $x_1$ 是房屋面积，最大接近 2000；$x_2$ 是卧室数量，最大只有 5。\n对于面积 2000、5 间卧室的房屋，一组合适的参数可能是：\n$$w_1=0.1,\\quad w_2=50,\\quad b=50$$预测为：\n$$0.1\\times2000+50\\times5+50=500$$单位是千美元，即 50 万美元。\n可以看到：\n数值范围大的特征，参数通常较小； 数值范围小的特征，参数通常较大。 这会让代价函数的等高线变得非常狭长：\n未缩放： 梯度下降在狭长峡谷两侧反复震荡 缩放后： 等高线更接近圆形 梯度下降更直接地靠近最低点 特征缩放通常不会改变模型能够达到的最优结果，主要作用是让训练更快、更稳定。\n7. 三种特征缩放方法 7.1 除以最大值 $$x_j' = \\frac{x_j}{\\max(x_j)}$$例如：\n面积范围：300～2000 缩放后：0.15～1 卧室范围：0～5 缩放后：0～1 7.2 均值归一化 $$x_j' = \\frac{x_j-\\mu_j} {\\max(x_j)-\\min(x_j)}$$减去均值后，数据会以 0 为中心。\n7.3 Z-score 标准化 $$x_j' = \\frac{x_j-\\mu_j}{\\sigma_j}$$其中：\n$$\\mu_j = \\frac{1}{m} \\sum_{i=0}^{m-1}x_j^{(i)}$$$$\\sigma_j = \\sqrt{ \\frac{1}{m} \\sum_{i=0}^{m-1} \\left(x_j^{(i)}-\\mu_j\\right)^2 }$$处理后，每个特征通常具有：\n均值接近 0； 标准差接近 1； 各特征具有相似的数值范围。 NumPy 实现：\ndef zscore_normalize_features(X): mu = np.mean(X, axis=0) sigma = np.std(X, axis=0) X_norm = (X - mu) / sigma return X_norm, mu, sigma 使用：\nX_norm, X_mu, X_sigma = zscore_normalize_features(X_train) 实验中，未缩放数据只能使用约 1e-7 的学习率；经过 Z-score 标准化后，可以从 0.1 左右开始尝试，收敛速度明显加快。\n8. 预测新数据时的关键规则 标准化训练数据后，必须保存训练集的均值和标准差。\n预测面积 1200 平方英尺、3 间卧室、1 层、房龄 40 年的房屋：\nx_house = np.array([1200, 3, 1, 40]) x_house_norm = ( x_house - X_mu ) / X_sigma price = x_house_norm @ w_norm + b_norm 不能对新样本重新计算均值和标准差。\n错误：\n# 不要这样做 new_mu = np.mean(x_house) new_sigma = np.std(x_house) 正确原则：\n训练集：计算并保存 mu、sigma 验证集：使用训练集的 mu、sigma 测试集：使用训练集的 mu、sigma 新数据：使用训练集的 mu、sigma 否则训练和预测使用的不是同一个坐标系。\n9. 如何判断梯度下降是否收敛？ 记录每次更新后的代价：\nJ_history.append( compute_cost(X, y, w, b) ) 绘制学习曲线：\nplt.plot(J_history) plt.xlabel(\u0026#34;Iteration\u0026#34;) plt.ylabel(\u0026#34;Cost J\u0026#34;) plt.show() 正常曲线：\nJ │\\ │ \\ │ \\____ │ ─── └──────────── 迭代次数 如果代价持续下降并逐渐变平，说明梯度下降正在收敛。\n也可以使用自动收敛条件：\n$$\\left| J^{(t)}-J^{(t-1)} \\right|\u003c\\epsilon$$例如：\nif abs(J_history[-1] - J_history[-2]) \u0026lt; 1e-3: break 但实际学习中，直接观察学习曲线通常更直观。\n10. 梯度下降调试方法 10.1 代价不断增加 可能原因：\n学习率太大； 参数更新误写成了加号； 梯度公式实现错误。 错误：\nw = w + alpha * dj_dw 正确：\nw = w - alpha * dj_dw b = b - alpha * dj_db 10.2 使用很小的学习率进行测试 把 alpha 设置得非常小。\n如果代码正确，代价函数理论上应该缓慢下降。如果很小的学习率下代价仍然增加，通常说明代码存在错误。\n需要注意：\n很小的学习率适合调试，但不一定适合正式训练，因为它可能导致收敛非常慢。\n10.3 尝试一系列学习率 可以按大约 3 倍的间隔尝试：\n0.001 0.003 0.01 0.03 0.1 0.3 1 选择能够让代价快速、持续下降的较大学习率。\n11. 特征工程 特征工程是利用业务知识，从已有特征构造更有意义的新特征。\n例如：\n$x_1$：土地宽度； $x_2$：土地深度。 可以构造土地面积：\n$$x_3=x_1x_2$$代码：\narea = width * depth 模型变为：\n$$f(\\mathbf{x}) = w_1x_1+w_2x_2+w_3x_3+b$$相比只使用宽度和深度，面积可能与房价具有更直接的关系。\n12. 多项式回归 假设真实数据为：\n$$y=1+x^2$$如果只使用原始特征 $x$，线性回归只能拟合直线：\n$$f(x)=wx+b$$可以构造平方特征：\nx = np.arange(0, 20, 1) y = 1 + x ** 2 X = (x ** 2).reshape(-1, 1) 模型变为：\n$$f(x)=w_1x^2+b$$虽然它对原始变量 $x$ 是非线性的，但对参数 $w_1,b$ 仍然是线性的，所以仍可以使用线性回归的训练方法。\n不知道应该使用几次项时，可以构造多个候选特征：\nX = np.c_[ x, x ** 2, x ** 3 ] 对应模型：\n$$f(x) = w_1x+w_2x^2+w_3x^3+b$$多项式特征必须注意尺度 如果：\nx：1～1000 那么：\nx²：1～1,000,000 x³：1～1,000,000,000 尺度差异会非常大，因此应进行标准化：\nX = np.c_[x, x ** 2, x ** 3] X_norm, mu, sigma = zscore_normalize_features(X) 特征缩放后，可以使用更大的学习率，训练速度会明显提高。\n13. 使用 Scikit-Learn Scikit-Learn 可以直接完成标准化和梯度下降回归。\nimport numpy as np from sklearn.linear_model import SGDRegressor from sklearn.preprocessing import StandardScaler 13.1 标准化训练数据 scaler = StandardScaler() X_norm = scaler.fit_transform(X_train) fit_transform() 包含两步：\nfit → 从训练集计算均值和标准差 transform → 使用这些统计量转换数据 13.2 创建并训练模型 sgdr = SGDRegressor( max_iter=1000, random_state=0 ) sgdr.fit(X_norm, y_train) 查看训练信息：\nprint(sgdr.n_iter_) # 实际迭代轮数 print(sgdr.t_) # 权重更新次数 查看参数：\nw_norm = sgdr.coef_ b_norm = sgdr.intercept_ print(w_norm) print(b_norm) 这些参数对应的是标准化后的输入数据，不能直接按原始面积、卧室数量的单位解释。\n13.3 进行预测 y_pred_sgd = sgdr.predict(X_norm) 也可以手动计算：\ny_pred_manual = X_norm @ w_norm + b_norm 检查两种结果：\nnp.allclose( y_pred_sgd, y_pred_manual ) 不要使用：\n(y_pred_sgd == y_pred_manual).all() 浮点数运算可能存在很小的舍入误差，应使用 np.allclose()。\n13.4 预测新房屋 x_house = np.array([ [1200, 3, 1, 40] ]) x_house_norm = scaler.transform(x_house) price = sgdr.predict(x_house_norm) print(price) 对新数据只能调用：\nscaler.transform() 不能再次调用：\nscaler.fit_transform() 因为重新 fit 会改变标准化使用的均值和标准差。\n13.5 SGD 与批量梯度下降的区别 手写实验使用的是批量梯度下降：\n使用全部训练样本 → 计算一次平均梯度 → 更新一次参数 SGDRegressor 使用随机梯度下降：\n依次使用单个样本或小批次 → 更频繁地更新参数 两者都在尝试降低代价函数，但参数更新方式不同。\n14. Notebook 中的 NameError 实验中出现：\nNameError: name \u0026#39;plt\u0026#39; is not defined NameError: name \u0026#39;load_house_data\u0026#39; is not defined NameError: name \u0026#39;run_gradient_descent\u0026#39; is not defined 这通常不是模型或公式的问题，而是依赖单元格没有运行。\nNotebook 中的变量只存在于当前内核的内存里。重启内核、断线或者跳过前面的单元格，都会导致变量不存在。\n正确运行顺序：\n1. 运行 import 单元格 2. 运行辅助函数单元格 3. 加载训练数据 4. 标准化数据 5. 训练模型 6. 计算预测 7. 绘制图像 如果 Notebook 状态混乱，可以使用：\nRestart Kernel and Run All 15. 高频易错点 w 和 b 必须同步更新 同一轮参数更新必须使用更新前计算出的同一组梯度。\n更新公式使用减号 梯度指向代价上升方向，因此需要减去梯度。\n特征尺度差异会拖慢收敛 面积、卧室数量、楼层、房龄应进行缩放。\n预测新数据必须复用训练集统计量 不能为新样本重新计算均值和标准差。\n多项式特征更需要缩放 $x,x^2,x^3$ 的数值范围可能相差多个数量级。\n浮点数不要直接使用 == 比较 使用 np.isclose() 或 np.allclose()。\n权重较大不一定代表特征更重要 权重大小受到特征尺度影响，也不代表因果关系。\nSGDRegressor 不是批量梯度下降 它使用随机梯度下降，只是优化目标与线性回归相似。\n16. 一页实践模板 import numpy as np from sklearn.preprocessing import StandardScaler from sklearn.linear_model import SGDRegressor # 1. 准备数据 X_train, y_train = load_house_data() # 2. 标准化 scaler = StandardScaler() X_norm = scaler.fit_transform(X_train) # 3. 训练 model = SGDRegressor( max_iter=1000, random_state=0 ) model.fit(X_norm, y_train) # 4. 训练集预测 y_pred = model.predict(X_norm) # 5. 新数据预测 x_new = np.array([ [1200, 3, 1, 40] ]) x_new_norm = scaler.transform(x_new) prediction = model.predict(x_new_norm) # 6. 检查手动计算 manual_prediction = ( X_norm @ model.coef_ + model.intercept_ ) print(np.allclose( y_pred, manual_prediction )) 整组实验最重要的主线是：\n准备训练数据 → 检查特征尺度 → 使用训练集统计量标准化 → 选择学习率 → 运行梯度下降 → 观察代价是否持续下降 → 保存参数和标准化器 → 用相同的转换处理新数据 → 进行预测 一句话总结：\n梯度下降负责寻找参数，特征缩放负责让寻找过程更快、更稳定，学习曲线负责判断训练是否正常，特征工程负责让线性模型表达更复杂的关系。\n","date":"2026-07-27T00:00:00+08:00","image":"/MyBlog/p/coursera-ml-gradient-descent-practice/cover.svg","permalink":"/MyBlog/p/coursera-ml-gradient-descent-practice/","title":"梯度下降实践：从手写实现到 Scikit-Learn"},{"content":" 平庸的生活方式是麻醉药。他只会束缚你，让你没有作为，甚至没有出息的度过大学的四年。 ——《交大生存手册》\n进入山威，是运气作祟。或者老天爷看我高中经历实在不忍心，给这孩子悲惨的高中生活以问号结尾，勉强给他画个句号吧。也许存着点玩笑意味，让我经历三四次大起大落，在这简略的提起吧。\n我始终不觉得我比别人付出的努力少。高中三年，无论是笔记、网课、作业、学校的高压练习、人际关系的拷打、身体的不适，一切的一切，都是我确切比周遭同学多经历的。可能是因为迟迟没有成长吧，所以相对应还是很幼稚的，可以说我没有开智哈哈哈。到最后三次模考，约莫也是 800～900 名的水平，可以说是波动很小的。到了高考，却很不一样。对卷子的影响出了考场就已模糊，但是成绩倒是惨惨淡淡的——559 分，3435 名。我不会忘记这哑然失笑的成绩与名次。接受吧，又能如何呢。\n着手报志愿，四个冲的，几个稳定。原本打算去央民 CS 保底来着，或许很大的概率能去 UIBE 学什么精算，或者海关。直到投档线出来的夜晚，发现自己落榜外经贸，便以为去央民吧。谁料细细对照，山威爆冷了。这无疑是令我开心的，被好一点的 985 捞了。但是，好似我从来没有了解过山威的专业。没事没事，志愿填报老师和我说过，电子信息不用说，海洋科学是做研究的，经济学嘛大差不差吧。当时填报志愿三者皆可，于是电子信息—海洋科学—经济学，如此排位。\n时间回到那个夜晚。和父母分享喜悦之后，去看看专业吧。这一看便让我的心跌入谷底——本来算是意外之喜，电子信息肯定够不上了，所以大概率 99% 是海洋科学。看我搜索一波吧：映入眼帘的是天坑专业、生化环材的集大成者、毕业底薪、累……一系列词条。至此一夜未眠，遂开始搜罗转专业的事项，这一来就是三四天。\n待到可以查询录取结果的那天，进入官网，点击查询，映入眼帘的竟然是——电子信息。是的，垂怜之下，我大概是以前五狗运的捡漏进入电子信息大类。人生大起大落，在短短三四天水落石出。\n进入山大之后，开启迷茫的大一生活。大一上是充满新奇的，从一个人跨越全中国来到威海，置办生活必需品，与来自其他地方的舍友相处，陌生的城市，陌生却莫名向往的海。\n军训可以暂且略过了，强度不大，也没有起到任何出乎意料的作用。\n真正的大一生活从军训结束开始。面对五花八门的基础课程、独特的课程时间和空闲时间的绝对自主化，我感觉到还不错。除了这些课程有点小难，好像我并不是能完全 handle，即使坐在第一排也如此。\n可能是认真听着听着，耍会手机，到课程过半甚至学不进去直接玩手机了。这也太惨了，课后还需要听网课来补知识，这一点也不好。课余时间分给了手机、一点点游戏，还有图书馆。谁还没有怀揣个保研梦呢？老实说，也是为了保研努力过一次吧，认真做题、复习、鏖战图书馆。但是最后大一上的收效却是很差的。\n何尝不是好事呢？放弃了这条大多数人想争一争但是其实很坑的路。不过绩点倒是足够分流到计算机了，这是好事啊。现在看来计算机是我最适合的专业了，按照兴趣的话确实是这样。让我去学机器人、研究高深的自控？天呐，没那么有毅力。或者选择电子科学，对着最厌恶的电路？\n作为一个对大学好奇的人，我也在充分地尝试各种活动——演讲比赛、短剧大赛、配音大赛。吗的，尝试了一圈发现全他妈都是野鸡比赛。这里不是内陆的顶尖高校，有着充分的人文气息，这里的比赛没有太多意义，负责这些的领导也是野鸡，纯粹是浪费时间之作。\n大一上，跟着毛夏叶学姐打打相对应的比赛吧。依托一个老师、传承的项目比比皆是，但是毛夏叶学姐的好心也是能感受到的。其实没有什么成长，我干的活无非是修改一些文件、修改一些 PPT，但是这和我的专业有毛线关系？如果是为了综测，好的，这坨屎我吃。但是当我大一上考完期末之后便完全摒弃了这个想法，选择退出。\n大一上还有什么重要节点呢？FPG 的考核吧。肖家骏学长负责我的考核，下面 po 出来当时的考核内容，仅供回忆：\nLinux 和 Shell 入门\n难度： ★★★☆☆☆☆\n受试人： 石静远 负责人： 肖家骏\n最好的计算机路线图，CSDIY\n上海交通大学生存手册\nLinux 开发环境的搭建\n了解命令行（CLI）的含义，以及其和图形化界面（GUI）有什么区别 了解 Shell 基本指令和 Shell 的历史 了解 Linux 发展的历史，以及各个发行版之间的区别 为自己的电脑安装任意一个 Linux 发行版（零基础建议 Ubuntu 24.04 LTS），使用 WSL2/双系统而非虚拟机 自行选择使用 WSL2 还是双系统方式安装 学习 Git 的基本操作和 GitHub 的基本使用：用于学习 Git 的趣味交互网站 配置基本的开发工具链（编辑器/IDE） 了解如何在命令行环境编译并运行 C 语言代码 在完成任务时\n确保在纯英文环境中进行操作，增进对英文文档的理解 每次操作前思考其目的，尝试理解每个步骤背后的原理 熟悉你接触的每一个工具或项目的官方文档，遇到问题请尝试查阅官方文档 成功在命令行编译并运行 C 语言后，前往实验室进行最终考核 前置知识\n知识技能 描述 Linux 免费开源的操作系统，就像 Windows 和 macOS，但你可以完全控制并自由定制它 Shell 命令行界面，让用户以文本指令与操作系统交互 C/C++ 编译器 将高级语言代码转换为机器码或字节码的程序 Vim 强大的文本编辑器，通过键盘命令快速编辑文件，初学曲线陡峭，熟练后效率极高 检查形式： 现场检查，使用现场提供的电脑，操作终端完成要求的命令。\n截止日期： 请在 12 月 6 日前联系团队以完成检查。\n现在看来，大致的内容都很基本，但在当时看来却是全新的。这些新奇的家伙让我感受很好，有了慢慢进步的感觉。我每天去准备些相关的考核内容，其实主要还是问问豆包，继而看看上面推荐的网页。对于 CS DIY 我觉得里面的课程要求较高、过于生涩；对于交大笔记倒是令我大跌眼镜，当时看来大逆不道的话，现在看来却是如此。总而言之给我的震撼很大，也算是开启了新的大门。准备的大差不差，考核通过，之后便无消息，除了被拉进群以外。\n这大致是我大一上——探索居多，什么都参加了一点，广度优先。但说起深度，好像并不是很有成效。这也是最迷茫的一学期。\n待到大一下学期开始，相对明确的我开始逐渐探索新的知识内容，对于课内知识一笑了之，自然是能不去则不去。这也造就了虚浮的基础。我不是那种随便突击几天便可以有很大成效、能够拿下 4.0 绩点的人，相反，我的绩点来之不易，即使慢慢学，也未必有高分。\n在下学期，真正的转折节点是二月的 FPG 团建。那天的经历并不重要，但是自从那天，我成为了实验室的常客。人总在交汇中成长，和肖家骏学长、宋博文学长以及各个学长交流，总是拓宽了很多眼界。看似没有一个实际的成果，但是基础薄弱，无可奈何，总归是在见识和其他潜藏的方面慢慢进步了。\n也是在这一学期，我看了 Missing Semester、CS61A，倒腾了 Linux Ubuntu 的输入法，有了自己的博客，慢慢是 GitHub 的常客，开始有意识去看重实习，加入了一位老师的科研。我认为这对我的综合成长是巨大的——醒悟的有点晚，再加上学校从期中考试延伸到期末考试，时间跨度长，感觉没法专心学习自己的内容。对于其他旁枝的知识了解的更是越来越多，我可以大言不惭地称之为“进化前期”吧。所谓进化，指的是在一段时间中，一个人的见解或者工程能力得到了巨大的提升。\n只能寄托进化于暑假吧。这个暑假，我打算：\n认真刷 LeetCode 反复夯实一个 Go 写的 Feed 流系统 至于八股，开学再背也不迟 希望寒假能找到具体的实习，这是我短期的小目标。\n对了，下学期我要认真听课，争取高点的绩点。\n此外对于扩展，我还是希望能再做下 Missing Semester 2026 的作业内容，感觉对于一些知识的运用还是没有那么常规化，譬如 Git 之类。此外看看 Coursera 上的 ML 入门，认真参与一个未名超算队 × LCPU AI Infra Seminars 的暑期夏令营。\n再细读两本书：《亿级流量系统架构设计与实战》——这是本好书，深入浅出；《打开量化投资的黑箱》。越来越能感受到，当 AI 时代来临，我们本以为知识可以通过反复向 AI 求索得到而忽略书本的重要性，但实际上书本的体系化是无与伦比的高知识浓度体系压缩。\n再有就是希望认真锻炼、保持健康，这才是一切的本钱。\n在此感叹下，高考的分界重要，着实是无与伦比。北大相对应俱乐部成员才大一，但是这一年的知识增速与累积，已经大于了很多中 9 大三学生的知识量。他们的机会多，成长也快。人终将走向异化，每个人的路都由着自己决定。希望尽量向上，能够不负时间、不负自己。这也许是一个简短的期许吧。\n好似也没什么了。大一一年带给我的成长，远远比过去十八年任何一年带来的多。我是幸运的，歪打正着进入想学的专业，有着很多前辈毫无保留的教导，有着父母的不懈支持。我还有什么倦怠而不去做的呢？\n从求而不得，到生生不息。 这是我对大一的注脚。\n","date":"2026-07-26T00:00:00+08:00","image":"/MyBlog/p/freshman-year-review/cover.svg","permalink":"/MyBlog/p/freshman-year-review/","title":"大一回顾：求不得，生不息"},{"content":"1. 多元线性回归 1.1 从单特征到多特征 单变量线性回归只使用一个特征：\n$$ f_{w,b}(x)=wx+b $$多元线性回归同时使用多个特征。例如预测房价时，可以使用：\n$x_0$：房屋面积 $x_1$：卧室数量 $x_2$：楼层数 $x_3$：房屋年龄 若共有 $n$ 个特征，则一个样本写成：\n$$ \\mathbf{x}^{(i)} = \\left[x_0^{(i)},x_1^{(i)},\\ldots,x_{n-1}^{(i)}\\right] $$其中：\n$i$ 表示第几个训练样本； $j$ 表示第几个特征； $x_j^{(i)}$ 表示第 $i$ 个样本的第 $j$ 个特征。 数学教材常从 1 开始编号；Python 和本实验从 0 开始编号。\n1.2 模型 多元线性回归模型为：\n$$ f_{\\mathbf{w},b}(\\mathbf{x}) =w_0x_0+w_1x_1+\\cdots+w_{n-1}x_{n-1}+b $$使用向量点积可以简写为：\n$$ \\boxed{f_{\\mathbf{w},b}(\\mathbf{x})=\\mathbf{w}\\cdot\\mathbf{x}+b} $$其中：\n$\\mathbf{w}=[w_0,w_1,\\ldots,w_{n-1}]$ 是长度为 $n$ 的参数向量； $b$ 是标量偏置； $\\mathbf{w}\\cdot\\mathbf{x}=\\sum_{j=0}^{n-1}w_jx_j$。 例如：\n$$ f(\\mathbf{x})=0.1x_0+4x_1+10x_2-2x_3+80 $$如果房价单位为千美元，可解释为：面积每增加 1 平方英尺，预测价格增加 0.1 千美元；房龄每增加 1 年，预测价格减少 2 千美元。参数表达的是“其他特征保持不变时”的线性影响。\n“多元线性回归”指多个输入特征。它不同于统计学中具有多个输出变量的“多变量回归”。\n2. 训练数据的矩阵表示 若有 $m$ 个训练样本，每个样本有 $n$ 个特征，则输入数据组成矩阵：\n$$ \\mathbf{X}\\in\\mathbb{R}^{m\\times n} $$ 每一行是一个训练样本； 每一列是一种特征； X[i, j] 是第 i 个样本的第 j 个特征； X[i] 或 X[i, :] 是第 i 个样本的特征向量。 实验数据：\nimport numpy as np X_train = np.array([ [2104, 5, 1, 45], [1416, 3, 2, 40], [ 852, 2, 1, 35] ]) y_train = np.array([460, 232, 178]) 其形状为：\nX_train.shape # (3, 4)：3 个样本、4 个特征 y_train.shape # (3,)：3 个目标值 参数示例：\nw_init = np.array([ 0.39133535, 18.75376741, -53.36032453, -26.42131618 ]) b_init = 785.1811367994083 w_init.shape # (4,) 3. 用 NumPy 实现预测 3.1 循环版本 def predict_single_loop(x, w, b): p = 0.0 for j in range(x.shape[0]): p += x[j] * w[j] return p + b 3.2 向量化版本 def predict(x, w, b): return np.dot(x, w) + b 使用方式：\nx_vec = X_train[0] prediction = predict(x_vec, w_init, b_init) x_vec 和 w_init 的形状都是 (4,)，np.dot(x_vec, w_init) 返回一个标量。\n向量化版本的优势：\n代码更短、更容易阅读； 底层使用经过优化的数值计算程序； 可利用 CPU 的并行指令及专门的线性代数库； 对大规模数据通常远快于 Python 层的显式循环。 向量化并不意味着“所有运算一定在一个时钟周期完成”，而是将循环交给优化后的底层实现，从而减少 Python 解释器开销并更充分地利用硬件。\n4. 代价函数 单个样本的误差为：\n$$ e^{(i)} =f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} $$平方误差代价函数：\n$$ \\boxed{ J(\\mathbf{w},b) =\\frac{1}{2m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right)^2 } $$Python 实现：\ndef compute_cost(X, y, w, b): m = X.shape[0] cost = 0.0 for i in range(m): prediction = np.dot(X[i], w) + b cost += (prediction - y[i]) ** 2 return cost / (2 * m) 进一步向量化：\ndef compute_cost_vectorized(X, y, w, b): errors = X @ w + b - y return np.sum(errors ** 2) / (2 * X.shape[0]) 其中 @ 在这里执行矩阵与向量的乘法：\nX : (m, n) w : (n,) X @ w : (m,) y : (m,) 5. 多元线性回归的梯度 对每个参数 $w_j$：\n$$ \\boxed{ \\frac{\\partial J}{\\partial w_j} =\\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right)x_j^{(i)} } $$对偏置 $b$：\n$$ \\boxed{ \\frac{\\partial J}{\\partial b} =\\frac{1}{m} \\sum_{i=0}^{m-1} \\left( f_{\\mathbf{w},b}(\\mathbf{x}^{(i)})-y^{(i)} \\right) } $$循环实现：\ndef compute_gradient(X, y, w, b): m, n = X.shape dj_dw = np.zeros(n) dj_db = 0.0 for i in range(m): error = np.dot(X[i], w) + b - y[i] for j in range(n): dj_dw[j] += error * X[i, j] dj_db += error return dj_db / m, dj_dw / m 完全向量化实现：\ndef compute_gradient_vectorized(X, y, w, b): m = X.shape[0] errors = X @ w + b - y dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m return dj_db, dj_dw 形状检查：\nerrors : (m,) X.T : (n, m) X.T @ errors : (n,) dj_dw : (n,) dj_db : scalar 6. 批量梯度下降 更新规则：\n$$ w_j \\leftarrow w_j-\\alpha\\frac{\\partial J}{\\partial w_j} $$$$ b \\leftarrow b-\\alpha\\frac{\\partial J}{\\partial b} $$向量形式：\n$$ \\boxed{ \\mathbf{w} \\leftarrow \\mathbf{w}-\\alpha\\nabla_{\\mathbf{w}}J } $$实现：\nimport copy import math def gradient_descent( X, y, w_in, b_in, cost_function, gradient_function, alpha, num_iters ): w = copy.deepcopy(w_in) b = b_in J_history = [] for i in range(num_iters): dj_db, dj_dw = gradient_function(X, y, w, b) # 使用同一次迭代计算出的梯度，同时更新所有参数 w = w - alpha * dj_dw b = b - alpha * dj_db if i \u0026lt; 100_000: J_history.append(cost_function(X, y, w, b)) if i % math.ceil(num_iters / 10) == 0: print(f\u0026#34;Iteration {i:4d}: Cost {J_history[-1]:8.2f}\u0026#34;) return w, b, J_history 实验设置：\ninitial_w = np.zeros_like(w_init) initial_b = 0.0 iterations = 1000 alpha = 5.0e-7 w_final, b_final, J_hist = gradient_descent( X_train, y_train, initial_w, initial_b, compute_cost, compute_gradient, alpha, iterations ) 该实验的学习率很小，而且不同特征的数值范围差异大，因此 1000 次迭代后的预测仍不够准确。后续通常会通过特征缩放改善收敛速度。\n7. NumPy 向量基础 7.1 一维数组 本课程用一维 NumPy 数组表示向量：\na = np.array([1, 2, 3, 4]) a.shape # (4,) 常用创建方式：\nnp.zeros(4) # 4 个 0 np.random.random_sample(4) # 4 个 [0, 1) 随机数 np.arange(4.0) # [0., 1., 2., 3.] np.random.rand(4) # 4 个 [0, 1) 随机数 (4,) 表示含 4 个元素的一维数组，不是二维行矩阵 (1, 4)，也不是二维列矩阵 (4, 1)。\n7.2 索引 a = np.arange(10) a[2] # 第 3 个元素 a[-1] # 最后一个元素 访问一维数组的单个元素通常得到 NumPy 标量。\n7.3 切片 格式：\na[start:stop:step] stop 不包含在结果中。\na[2:7:1] # 索引 2、3、4、5、6 a[2:7:2] # 索引 2、4、6 a[3:] # 从索引 3 到末尾 a[:3] # 索引 0、1、2 a[:] # 全部元素，得到一个视图 7.4 常用向量运算 a = np.array([1, 2, 3, 4]) -a # 逐元素取负 np.sum(a) # 元素求和，返回标量 np.mean(a) # 均值，返回标量 a ** 2 # 逐元素平方 5 * a # 每个元素乘以 5 7.5 逐元素运算与点积 a = np.array([ 1, 2, 3, 4]) b = np.array([-1, 4, 3, 2]) a * b # 逐元素乘法，结果仍是向量 np.dot(a, b) # 点积：逐元素相乘后求和，结果是标量 a @ b # 对两个一维向量，同样是点积 手动实现点积：\ndef my_dot(a, b): result = 0.0 for i in range(a.shape[0]): result += a[i] * b[i] return result 两个向量做逐元素运算或点积时，形状必须兼容。\n7.6 向量性能示例 import time size = 10_000_000 a = np.random.rand(size) b = np.random.rand(size) start = time.time() vectorized_result = np.dot(a, b) vectorized_time = time.time() - start start = time.time() loop_result = my_dot(a, b) loop_time = time.time() - start 两种方法应给出近似相同的结果。浮点数运算顺序不同，可能造成极小的舍入误差，因此比较时宜使用：\nnp.isclose(vectorized_result, loop_result) 8. NumPy 矩阵基础 8.1 创建矩阵 二维数组使用形状元组：\nnp.zeros((1, 5)) # shape: (1, 5) np.zeros((2, 1)) # shape: (2, 1) np.random.random_sample((3, 4)) 手动创建：\na = np.array([ [5], [4], [3] ]) a.shape # (3, 1) 8.2 reshape a = np.arange(6).reshape(3, 2) 结果：\n[[0, 1], [2, 3], [4, 5]] 也可以写：\na = np.arange(6).reshape(-1, 2) -1 表示让 NumPy 根据元素总数和另一个维度自动推断该维度。\n8.3 矩阵索引 a[2, 0] # 第 3 行第 1 列的标量 a[2] # 第 3 行，返回 shape 为 (n,) 的一维数组 a[2, :] # 同上 a[:, 0] # 第 1 列，返回一维数组 8.4 矩阵切片 a = np.arange(20).reshape(-1, 10) a[0, 2:7] # 第 1 行中索引 2 到 6 a[:, 2:7] # 所有行、索引 2 到 6 的列 a[:, :] # 全部行和列 a[1, :] # 第 2 行，结果是一维数组 9. 广播（Broadcasting） 广播允许 NumPy 在形状兼容时，将较小的数组“扩展”到较大的数组上执行逐元素运算。\n最简单的例子是标量与向量：\na = np.array([1, 2, 3, 4]) a + 10 # [11, 12, 13, 14] 在线性模型中：\nX @ w + b X @ w 的形状是 (m,)，标量 b 会广播到每个预测值上。\n判断广播是否兼容时，从形状的最后一个维度向前比较；对应维度相等，或其中一个为 1，通常即可广播。\n广播很方便，但也可能隐藏形状错误。机器学习代码中应经常打印或断言 .shape。\n10. 正态方程与梯度下降 线性回归还可以通过线性代数方法直接求解参数，常称为正态方程法。\n特点：\n无需像梯度下降那样反复迭代； 主要用于线性回归，不能自然推广到逻辑回归、神经网络等模型； 当特征数很大时，直接求解可能较慢； 实际项目中通常交给成熟的数值计算或机器学习库处理，不应手动计算矩阵逆。 本课程重点学习梯度下降，因为它能推广到更多机器学习模型。\n11. 高频易错点 数学下标与 Python 下标不同\n数学讲解可能从 1 开始；NumPy 从 0 开始。\n(n,) 不等于 (n, 1)\n前者是一维数组，后者是二维列矩阵。\na * b 不等于 np.dot(a, b)\n前者逐元素相乘，后者对一维向量返回点积。\n切片右端不包含\na[2:7] 包含索引 2 到 6。\n更新梯度下降参数时要同步\n同一轮中的 w 和 b 都应使用更新前的同一组梯度。\n特征尺度会影响梯度下降速度\n面积可能是上千，楼层数可能只有 1～3；尺度差异大会使代价函数狭长，导致收敛缓慢。\n学习率过大或过小都不好\n过大可能使代价震荡或发散；过小则收敛很慢。\n浮点数结果不要直接用 == 比较\n使用 np.isclose 或 np.allclose。\n课程原文中的翻译错误\n向量 [1,2,3,4] 的 shape (4,) 是一维，不是“二维向量”；向量的元素个数常称为长度或维数，而 NumPy 数组的 ndim 表示轴的数量。\n12. 一页速记 # 预测 y_hat = X @ w + b # 误差 errors = y_hat - y # 代价 J = np.sum(errors ** 2) / (2 * m) # 梯度 dj_dw = X.T @ errors / m dj_db = np.sum(errors) / m # 更新 w = w - alpha * dj_dw b = b - alpha * dj_db 对应的核心公式：\n$$ \\boxed{ \\hat{\\mathbf{y}}=\\mathbf{X}\\mathbf{w}+b } $$$$ \\boxed{ J(\\mathbf{w},b)=\\frac{1}{2m}\\|\\hat{\\mathbf{y}}-\\mathbf{y}\\|_2^2 } $$$$ \\boxed{ \\nabla_{\\mathbf{w}}J =\\frac{1}{m}\\mathbf{X}^{T}(\\hat{\\mathbf{y}}-\\mathbf{y}) } $$$$ \\boxed{ \\frac{\\partial J}{\\partial b} =\\frac{1}{m}\\sum_{i=0}^{m-1}(\\hat y^{(i)}-y^{(i)}) } $$掌握这一条主线即可：\n样本矩阵 X → 点积得到预测 → 预测减目标得到误差 → 由误差计算代价和梯度 → 用梯度下降更新 w、b ","date":"2026-07-26T00:00:00+08:00","image":"/MyBlog/p/coursera-ml-multiple-linear-regression/cover.svg","permalink":"/MyBlog/p/coursera-ml-multiple-linear-regression/","title":"多元线性回归与 NumPy 向量化"},{"content":" 面向刚进入 Infra / AI Infra 领域的初学者 课程时间：2026-07-25 整理依据：96 页课件 + 约 3 小时逐字稿 说明：本文不是逐页复述，而是按知识依赖关系重构。逐字稿中的语音识别错误（如把 warp 写成 work、grid 写成 grade、Triton 写成 ten、TileLang 写成 tell long）均已按上下文纠正。\n0. 先给出整场讲座的主线 这场讲座其实在回答一个连续的问题：\n为什么 AI 需要并行计算？ 大模型中的矩阵乘法、逐元素运算、通信和流水线，都含有大量可以同时完成的工作。 为什么 GPU 适合做这些计算？ GPU 用大量计算单元和高带宽内存换取高吞吐量，并通过并发执行许多 warp 来隐藏单个任务的延迟。 程序员怎样把计算映射到 GPU？ CUDA 用 grid → block/CTA → warp → thread 描述并行任务，并要求程序员显式处理索引、内存、同步和边界。 为什么还需要 Triton / TileLang？ 手写每个线程过于繁琐。Tile-level DSL 让程序员先描述\u0026quot;一块数据如何搬运和计算\u0026quot;，再由编译器完成更细的线程映射。 这与 AI Infra 有什么关系？ 算子、编译器、分布式训练、集群通信、推理服务和强化学习系统，本质上都在解决： 怎样切分计算、放置数据、协调执行，并让昂贵硬件尽可能持续地做有效工作。 可以把全文压缩成一句话：\nAI Infra 的核心不是\u0026quot;GPU 比 CPU 快\u0026quot;，而是识别并行性，并在计算、内存和通信之间设计高效的数据流。\n1. 活动定位与课程地图 本次暑期活动的目标是\u0026quot;学习和实践 AI Infra\u0026quot;，除课程外还计划提供算力、练习、评测排行、交流讨论和业界连接。整体内容分为四个主题：\n主题 主要内容 Kernel 与 ML Compiler CUDA、GPU 编程、DSL、数据布局、流水线、编译器栈、性能上限分析 Interconnect 与 Communication Scale-up / Scale-out、网络互联、P2P、集合通信、MoE/EP、通信计算重叠、存储协同 LLM Serving 与 Inference KV Cache、vLLM/SGLang、算子与框架、并行策略、Prefill-Decode 分离、投机解码 Distributed RL Systems RL 算法、Rollout、训推一致性、分布式 RL、Agentic RL Session 1 是后续所有主题的\u0026quot;地基层\u0026quot;：先建立并行计算、GPU 执行模型和 Kernel 编程的共同语言。\n第一部分：从计算到并行计算 2. 什么是计算 课程给出的朴素定义是：\n计算是把输入值按照某种规则变换为输出值的过程。\n这个定义可以拆成两件事：\n数据及其搬运：输入在哪里？输出写到哪里？中间数据怎样流动？ 计算规则及其执行单元：加法、乘法、比较、矩阵乘法由什么硬件完成？ 这是一个很重要的 Infra 视角。应用开发常把\u0026quot;算法\u0026quot;当作核心，但性能工程会同时追问：\n算了多少次？ 每次计算需要读写多少字节？ 数据位于寄存器、缓存、显存，还是另一张卡？ 计算单元是在工作，还是在等数据？ 很多程序\u0026quot;算得慢\u0026quot;并不是算术运算慢，而是数据没有及时到达计算单元。\n3. 指令、时钟周期、流水线与 IPC 一个经典处理器执行指令大致经过：\n取指 → 译码 → 执行 → 访存 → 写回\n如果把每条指令完整做完才开始下一条，硬件的大量部件会闲置。流水线让不同指令同时处于不同阶段。例如：\n指令 1 正在写回； 指令 2 正在访存； 指令 3 正在执行； 指令 4 正在译码； 指令 5 正在取指。 IPC（Instructions Per Cycle） 表示平均每周期完成多少条指令。需要注意：\nIPC 不是频率；频率表示一秒有多少周期。 IPC 也不是程序性能的全部；不同指令的工作量、延迟和吞吐不同。 超标量处理器能在同一周期发射或完成多条互不依赖的指令，因此 IPC 可能大于 1。 这已经是一种并行：它发生在单个处理器内部，称为指令级并行（ILP）。\n4. 延迟与吞吐量 这是全场最重要的一组区分。\n延迟（Latency） 完成一个任务要等多久。\n例：一个请求从进入推理服务到收到第一个 token 的时间。\n吞吐量（Throughput） 单位时间完成多少任务或多少计算。\n例：一个推理集群每秒处理多少 token。\nGPU 的优势主要是吞吐量，而不是每个单独运算的最低延迟。它的策略是：\n当一个任务在等待数据时，迅速执行另一个已就绪的任务，用大量并发工作隐藏等待。\n因此，\u0026ldquo;GPU 快\u0026quot;必须补全为：\n对具有足够并行度、规则控制流和合适访存模式的工作负载，GPU 往往能提供很高的总吞吐量。\n5. 什么样的计算能够并行 5.1 数据依赖 c = a * b d = 3 * c d 必须等待 c，二者存在真数据依赖，不能直接同时执行。\nc = a * b d = 3 * b e = a + b 三条计算只读相同输入、写不同输出，可以并行。\n更系统地说，判断能否并行要看读写集合是否冲突。常见依赖包括：\nRAW（Read After Write）：后者要读前者写出的值，是真依赖。 WAR（Write After Read）：后者写入的位置仍需被前者读取。 WAW（Write After Write）：两个任务写同一位置，最终结果依赖顺序。 5.2 竞争、互斥和锁 多个执行单元同时读写同一资源，可能产生 race condition。锁、原子操作、屏障可以恢复正确性，但会带来等待和串行化。\n所以，并行化不只是\u0026quot;把任务分成 N 份\u0026rdquo;，还要保证：\n每份任务的读写范围清楚； 共享状态受到正确保护； 同步频率不过高； 结果与执行顺序无关，或执行顺序被明确控制。 5.3 通信与同步 任务即使能拆开，也常常需要交换中间结果。例如：\n多卡训练中聚合梯度； 求所有设备上的最大值或总和； 流水线后一阶段等待前一阶段输出； MoE 中把 token 路由到不同专家。 并行收益最终要减去：\n通信时间； 同步等待； 数据重新布局； 任务调度； 负载不均衡。 这解释了为什么\u0026quot;设备数翻倍\u0026quot;通常不等于\u0026quot;性能翻倍\u0026quot;。\n6. 两类基本拆分方法 6.1 Domain Decomposition：按数据域拆分 把一个大数据集切成若干块，让不同计算单元处理不同区域。\n常见切法：\n连续分块（block）； 循环分配（cyclic）； block-cyclic； 二维或三维网格切分。 选择切法时要考虑：\n各块计算量是否均衡； 相邻数据是否频繁交互； 数据是否连续，能否高效访问； 每块能否放入更快的存储层。 矩阵乘法是典型例子。输出矩阵 C = A × B 的不同块可以独立计算，因此可以先按 M、N 维切分输出，再沿归约维 K 分块累加。\n6.2 Functional Decomposition：按功能或阶段拆分 把不同类型的计算放到不同执行单元，或让不同设备负责流水线的不同阶段。\n例：\nCPU 负责调度和控制，GPU 负责密集计算； 流水线并行中，不同 GPU 负责模型的不同层； 推理系统中，Prefill 和 Decode 由不同资源池承担。 6.3 大模型训练中的典型映射 并行方式 拆什么 主要代价 数据并行 DP 不同设备处理不同 batch，模型副本相同 梯度 All-Reduce 张量并行 TP 把单层矩阵按行或列拆到多卡 层内频繁通信 流水线并行 PP 不同设备负责不同层 流水线气泡、激活传输 专家并行 EP 不同设备放不同 MoE 专家 All-to-All、负载不均 序列/上下文并行 按序列维切分 注意力相关通信 现实系统经常组合多种并行方式，形成多维并行拓扑。\n7. Flynn 分类：从指令流与数据流看硬件 类型 含义 直觉 SISD 单指令流、单数据流 经典串行处理 SIMD 单指令流、多数据流 同一指令同时作用于多个数据元素 MISD 多指令流、单数据流 很少有严格对应的通用硬件 MIMD 多指令流、多数据流 多核 CPU、集群等通用并行系统 这个分类是抽象模型，不应把复杂现代处理器硬塞进唯一格子。GPU 在不同层级上会同时呈现不同特征：\n不同 block 可以执行不同任务，整体具有 MIMD 味道； 单个 warp 通常以相同指令推进，具有 SIMD/SIMT 味道。 8. 并行编程模型：程序员怎样描述计算 课程提到的主要模型包括：\n共享内存：执行单元通过同一地址空间读写数据。 线程模型：创建、同步和管理多个线程。 分布式内存 / 消息传递：不同进程拥有独立内存，通过 send/receive、broadcast、reduce 等显式通信；MPI 是代表。 PGAS：逻辑上提供全局地址空间，但数据具有物理归属。 SPMD：多个执行单元运行同一程序，但依据自身编号处理不同数据。 MPMD：不同执行单元可以运行不同程序。 混合模型：例如节点间 MPI、节点内线程、GPU 上 CUDA。 不要把 SPMD 和\u0026quot;数据并行训练\u0026quot;混为一谈：\nSPMD 描述\u0026quot;程序是否相同\u0026quot;； 数据并行描述\u0026quot;模型和数据如何切分\u0026quot;。 第二部分：CUDA 编程模型 9. 为什么 GPU 适合 AI/HPC 课程用\u0026quot;三胜\u0026quot;概括 GPU：\n9.1 更多晶体管用于吞吐型计算 CPU 要做好分支预测、乱序执行、低延迟缓存和复杂控制；GPU 把更多面积用于大量相对简单的计算单元。\n这不是\u0026quot;GPU 每个核心都比 CPU 核心强\u0026quot;，而是：\nCPU 强于低延迟、复杂控制和串行任务； GPU 强于规则、密集、批量、可并行的任务。 9.2 用并发隐藏延迟 GPU 的单次操作或显存访问可能并不低延迟，但 SM 保存大量 warp 的执行状态。一个 warp 等待内存时，调度器可以选择另一个就绪 warp。\n9.3 高显存带宽 训练和推理频繁搬运权重、激活和 KV Cache，高带宽显存非常关键。但标称带宽只有在访问足够连续、事务利用率高时才可能接近。\n课堂中的具体延迟、吞吐和\u0026quot;HBM 比 DDR 快多少倍\u0026quot;是用于建立直觉的示例，不是跨所有 CPU/GPU/内存配置都成立的常数。\n10. CUDA 的软件层级与硬件层级 10.1 软件视角 Kernel launch └── Grid ├── Block / CTA │ ├── Warp（NVIDIA 当前通常为 32 threads） │ │ ├── Thread │ │ └── ... │ └── ... └── ... thread：CUDA 编程中最细的逻辑执行实例。 warp：硬件调度与执行的一组线程。 block / CTA：线程合作、共享 shared memory 和执行块级同步的范围。 grid：一次 kernel launch 创建的所有 block。 10.2 硬件视角 GPU └── GPC 等更高层组织 └── 多个 SM ├── warp schedulers ├── register file ├── shared memory / L1 ├── CUDA cores / ALUs ├── Tensor Cores └── SFU 等专用单元 一个 block 会被调度到某个 SM，并通常在该 SM 上运行至结束。block 数可以远多于 SM 数，剩余 block 等待调度。\n10.3 自动可扩展性的来源 一般 CUDA kernel 应让不同 block 相互独立，因为：\nblock 的执行顺序没有保证； 设备可能有几十、几百或更多 SM； 同一程序无需根据 SM 数重写。 这是一种软件与硬件规模解耦。例外包括 cooperative launch、thread block cluster 等有额外约束的机制，但初学阶段先遵守\u0026quot;block 独立\u0026quot;最安全。\n11. CUDA 程序的基本生命周期 典型流程：\nCPU（host）准备输入； 分配 GPU（device）内存； 把数据从 host memory 复制到 device memory； 使用 \u0026lt;\u0026lt;\u0026lt;gridDim, blockDim, ...\u0026gt;\u0026gt;\u0026gt; 启动 kernel； GPU 并行执行； 必要时同步； 将结果复制回 CPU； 释放资源并检查错误。 CUDA C++ 中常见执行空间修饰符：\n__host__：在 CPU 上执行； __device__：在 GPU 上执行，由 device 代码调用； __global__：声明 kernel，通常由 host 发起并在 device 上执行，返回类型为 void。 编译链可粗略理解为：\nCUDA C++ (.cu) ├── host code → host compiler → CPU code └── device code → nvcc toolchain → PTX / device binary PTX 是虚拟指令集/中间表示，便于在不同代际 GPU 上进一步生成机器代码。 CUBIN 包含面向具体 GPU 架构的机器代码。 12. 从线程编号映射到数据 12.1 一维向量 int i = blockIdx.x * blockDim.x + threadIdx.x; if (i \u0026lt; N) { c[i] = a[i] + b[i]; } 其中：\nthreadIdx.x：线程在 block 内的局部编号； blockIdx.x：block 在 grid 内的编号； blockDim.x：每个 block 的线程数； 边界判断处理 N 不是 block 大小整数倍的情况。 12.2 二维矩阵 int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; if (row \u0026lt; M \u0026amp;\u0026amp; col \u0026lt; N) { c[row * N + col] = a[row * N + col] + b[row * N + col]; } 二维数组在内存中仍然是一段线性地址。以行主序为例：\naddress(i, j) = base + i * stride_row + j * stride_col 对连续张量，通常 stride_col = 1，stride_row = 列数。理解 stride 是之后看 Triton 矩阵乘法指针计算的关键。\n13. Warp、SIMT 与分支发散 13.1 SIMT SIMT 是 Single Instruction, Multiple Threads。程序员看到独立线程，但硬件通常以 warp 为单位发射指令。\nSIMT 与 SIMD 的直觉差异：\nSIMD 直接表达\u0026quot;一条向量指令作用于多个数据 lane\u0026quot;； SIMT 表达\u0026quot;许多逻辑线程运行同一 kernel\u0026quot;。 在 NVIDIA GPU 上，每个线程具有独立执行状态，但同一 warp 中的活跃线程仍以共同指令高效推进。\n13.2 Warp divergence 若同一 warp 内部分线程走 if，另一部分走 else，硬件可能依次执行不同路径，并 mask 掉当前路径不参与的线程。\nif (threadIdx.x % 2 == 0) { expensive_a(); } else { expensive_b(); } 这会让每个 warp 都经历两条路径。优化方向是让相同分支的数据按 warp 边界聚集，使一个 warp 尽量只执行一条路径。\n但不要把\u0026quot;任何 if 都很慢\u0026quot;当成规则：\n所有线程条件一致时没有发散； 很短的分支可能被编译为 predication； 边界判断通常只影响少量 warp； 真正是否成为瓶颈应由 profiler 判断。 13.3 Warp-level primitives warp 内线程可以通过 shuffle 等原语交换寄存器数据。例如 reduction 可在 log2(32) 轮中逐步求和，而不必经过 shared memory。\n14. Block 内协作与同步 同一 block 内的线程可以：\n访问 shared memory； 使用原子操作； 使用内存屏障； 使用 __syncthreads() 进行块级同步。 关键规则：\n__syncthreads() 必须由 block 中所有相关线程以一致的控制流到达，否则可能死锁或产生未定义行为。\n不能仅仅因为\u0026quot;每个线程最终都会调用一次\u0026quot;就认为安全；它们必须在相同的同步阶段到达。\nCompute Capability 9.0 起，CUDA 还提供可选的 thread block cluster，使一组 block 在受约束的硬件范围内协作，并可使用 distributed shared memory。它是进阶机制，不改变初学者优先保证普通 block 独立的原则。\n15. GPU 内存层次 层次 可见范围 容量/速度直觉 主要用途 Register 单个 thread 最小、最快 当前值、累加器 Shared memory 单个 block/CTA 小、低延迟、显式管理 数据复用、线程协作 L1 cache 通常 SM 层级 硬件管理 缓存局部访问 L2 cache 全 GPU 更大、更慢 跨 SM 数据缓存 Global memory / HBM 全 GPU 最大、高延迟、高总带宽 权重、激活、输入输出 Local memory 逻辑上 thread 私有，物理上通常在 device memory 可能很慢 寄存器不足时的 spill 等 Constant memory 全局只读且有缓存 适合 warp 内相同地址读取 常量参数 重要纠偏：\n\u0026ldquo;local\u0026quot;描述可见性，不保证物理位置靠近线程，也不保证快。 shared memory 与 L1 常共享片上资源或可配置容量，但具体组织取决于架构。 寄存器不是无限的；每线程用得太多，会减少同一 SM 可驻留的 warp/block。 16. 合并访存（Coalescing） 一个 warp 的线程访问 global memory 时，硬件会把地址请求合并为尽量少的内存事务。\n理想模式：\nlane 0 → A[k] lane 1 → A[k+1] lane 2 → A[k+2] ... 不理想模式：\n大步长跨越； 随机地址； 每个事务只使用很少字节； 未对齐访问导致额外事务。 这就是为什么行主序矩阵中，常把 threadIdx.x 映射到连续列：同一 warp 内连续线程访问连续地址。\n17. Tiling 与矩阵乘法 朴素矩阵乘法：\nC[m, n] = Σ_k A[m, k] * B[k, n] 一个高性能分块思路：\n每个 block 负责 C 的一个 BLOCK_M × BLOCK_N 输出块； 沿 K 维以 BLOCK_K 为步长循环； 每轮把 A、B 的对应 tile 从 global memory 搬到 shared memory； block 内线程复用这些数据并进行乘加； 结果在寄存器中累加； 最后写回 global memory。 核心收益不是减少数学运算，而是增加数据复用：\nA 的一个元素可参与同一输出块的多列计算； B 的一个元素可参与同一输出块的多行计算； 从 HBM 读取一次后，在 shared memory / register 中多次使用。 这正是后续 Triton 与 TileLang 都以 tile 为核心抽象的原因。\n18. Warp 调度、延迟隐藏与 Occupancy 18.1 延迟隐藏 当 warp A 等待 global memory 时，调度器可以发射 warp B。要做到这一点，SM 中必须有足够多的可运行 warp。\n18.2 Occupancy 常用定义：\nOccupancy = SM 上实际驻留的 active warps / 该 SM 支持的最大 active warps 限制驻留数量的资源包括：\n每 block 的线程数； 每线程的寄存器数； 每 block 的 shared memory； 架构对 block、warp 的数量上限。 18.3 高 Occupancy 不等于最高性能 更高 occupancy 通常有利于隐藏延迟，但可能与其他优化冲突：\n更多寄存器可减少 spill 和重复访存； 更大的 shared-memory tile 可提高数据复用； 但它们会降低可同时驻留的 block/warp 数。 所以目标不是机械追求 100%，而是找到足以隐藏主要延迟、同时保留高数据复用和低指令开销的配置。\n19. CUDA 优化检查表 课程总结的四个方向可以扩展成：\n暴露足够并行度：grid 足够大，能覆盖全部 SM。 提高数据复用：用 register/shared memory 减少重复 HBM 访问。 改善访存模式：合并访问、对齐、减少无效事务。 减少控制流浪费：降低 warp divergence。 控制资源使用：在寄存器、shared memory、occupancy 之间权衡。 使用专用单元：适合时使用 Tensor Core、SFU 等。 让搬运与计算重叠：异步拷贝、pipeline、多 stream。 基于测量优化：先验证正确性，再 benchmark，再 profiler。 API 选讲的准确理解 CUDA events：适合测量 GPU 时间；不要只用 CPU 墙钟且忘记异步执行。 Streams：同一 stream 内有序；不同 stream 的工作\u0026quot;有机会\u0026quot;并发，但是否真正重叠取决于依赖、资源、硬件能力和默认 stream 语义。 Unified Memory：简化地址与迁移管理，但数据迁移仍有成本；性能敏感场景需要关注预取、驻留和缺页。 P2P：在支持的拓扑与配置下允许 GPU 间直接访问或复制；不是所有 GPU 对都支持，性能也取决于 PCIe/NVLink 等互联。 第三部分：从 Thread-level 到 Tile-level 20. 为什么需要 Tile-level Programming CUDA 的控制力强，但程序员要显式完成：\n每个 thread 负责什么； thread/block 的索引如何映射到数据； global/shared/register 之间怎样搬运； 如何同步； 如何处理边界； 如何避免 bank conflict、发散和低效访存。 但很多 AI Kernel 的自然表述是：\n取一块张量 → 做一次块级计算 → 把结果写回。\n因此 Tile-level DSL 把 block 内的大量线程协作抽象成 tile 操作：\n确定当前 tile； 加载 tile； 计算； 写回； 用 mask 处理边界。 程序员仍决定关键分块，编译器负责把 tile 内计算映射到 thread/warp。\n21. Triton 编程模型 21.1 基本视角 CUDA 从 thread 出发； Triton 从 program instance 出发； 一个 Triton program 通常负责一个输出 tile； tl.program_id(axis=...) 类似取得当前 program 在 launch grid 中的位置； thread ID 通常不显式出现。 \u0026ldquo;一个 Triton program 约等于一个 CUDA block/CTA\u0026quot;是有用的初学者类比，但不是所有情况下都应理解成严格的一对一源码映射。\n21.2 向量加法 核心结构：\npid = tl.program_id(axis=0) offsets = pid * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) mask = offsets \u0026lt; n_elements x = tl.load(x_ptr + offsets, mask=mask, other=0.0) y = tl.load(y_ptr + offsets, mask=mask, other=0.0) z = x + y tl.store(z_ptr + offsets, z, mask=mask) 理解重点：\noffsets 是一组下标，不是单个线程的一个下标； mask 防止最后一个 tile 越界； other=0.0 为越界 load 提供填充值； 写法表达的是\u0026quot;整个 tile 的向量操作\u0026rdquo;。 21.3 矩阵乘法 每个 program 负责 C 的一个输出块：\nacc = zeros(BLOCK_M, BLOCK_N) for k0 in range(0, K, BLOCK_K): a_tile = load A[m:m+BLOCK_M, k0:k0+BLOCK_K] b_tile = load B[k0:k0+BLOCK_K, n:n+BLOCK_N] acc += dot(a_tile, b_tile) store C tile 这里有三个分块尺度：\nBLOCK_M：输出 tile 的行数； BLOCK_N：输出 tile 的列数； BLOCK_K：归约维每轮处理的长度。 指针计算依赖 stride：\nA[i,j] 地址 = A_base + i * stride_am + j * stride_ak B[i,j] 地址 = B_base + i * stride_bk + j * stride_bn 性能还受 program 执行顺序影响，因为相邻 program 是否复用同一批 A/B 数据会改变 L2 命中率。\n21.4 Triton 的真实优缺点 优点：\nPython 风格、代码短； 保留 tile shape、program 排列等重要决策； 适合 elementwise、reduction、matmul、attention 和融合算子； 支持 autotune； 可显著降低手写 CUDA 的工程成本。 代价：\n一些线程级布局、shared memory 组织和流水线由编译器决定； 当性能瓶颈恰好位于这些细节时，调优空间较受限； 复杂硬件特化和某些 pipeline 表达可能更困难。 课堂把编译器称为\u0026quot;黑盒\u0026rdquo;、说 PTX/SASS 不透明，是强调控制粒度的说法，不应理解为\u0026quot;完全无法查看生成代码\u0026quot;。实践中可以 dump IR/PTX、结合 profiler 分析；只是源码与最终机器行为之间的映射比手写 CUDA 更间接。\n22. TileLang 编程模型 TileLang 的目标位置可以理解为：\n保留 tile-level 的简洁，同时把影响性能的数据流和内存层次更明确地写出来。\n课程中的关键层次：\n函数参数 T.Tensor：global memory 中的输入输出； T.alloc_shared：CTA 内共享的 tile； T.alloc_fragment：寄存器中的局部累加结果； T.copy：不同内存层次之间搬运； T.gemm：tile 级矩阵乘加； T.Pipelined：沿分块循环表达搬运与计算重叠； T.Kernel(...)：定义 launch grid / CTA 级程序实例。 一个矩阵乘法的数据流可以写成：\nGlobal A/B │ T.copy ▼ Shared-memory tiles │ T.gemm ▼ Register fragment / accumulator │ T.copy ▼ Global C 其价值在于，读代码时能直接看到：\n数据在哪一层； 什么数据会被复用； 沿哪个维度循环； 搬运和计算是否流水化。 23. CUDA、Triton、TileLang 怎样选择 维度 CUDA C++ Triton TileLang 主要抽象 thread / warp / block program / tensor tile tile + 显式内存层次和数据流 控制粒度 最细 较高层 介于二者之间 开发效率 较低 高 中等 硬件细节负担 高 较低 中等 典型场景 极端调优、特殊机制、库级实现 快速开发与融合常见算子 需要显式数据流、pipeline 和可读结构的高性能 kernel 选择不是\u0026quot;谁永远更快\u0026quot;，而要问：\n算子是否规则？ 瓶颈是计算、访存、同步还是 launch overhead？ 是否需要特殊硬件指令或复杂流水线？ 是否需要跨硬件后端？ 团队能承担多少开发和维护成本？ 24. torch.compile 与手写 Kernel 的关系 课堂问答认为二者大体正交，这个方向是对的，但机制需要更精确地区分：\nTorchDynamo 负责从 Python 程序捕获可编译的计算图； TorchInductor 等后端进行融合、布局、调度和代码生成，GPU 上常生成 Triton kernel； CUDA Graphs 可用于减少 CPU launch overhead，但它不是 torch.compile 捕获 Python 计算图的同义词； 手写 Triton/TileLang/CUDA 仍有价值，因为通用编译器不一定能生成最适合特殊算子、数据布局或硬件特性的实现。 因此可以同时使用：\n模型/图级优化：torch.compile + 关键热点算子：手写或定制 Kernel 第四部分：把这些知识连回 AI Infra 25. AI Infra 的共同问题 层级 典型对象 核心问题 单条指令/单个 warp SIMD/SIMT、分支 lane 是否都做有效工作 单个 block/CTA tiling、shared memory 数据能否在片上复用 单个 GPU SM 调度、HBM、stream 是否计算饱和、带宽饱和、延迟被隐藏 多 GPU TP/PP/DP/EP、collectives 计算与通信如何重叠 单机推理服务 batching、KV Cache、调度 吞吐、首 token 延迟、显存容量如何权衡 集群 网络拓扑、容错、资源调度 如何扩展并保持利用率 同一个思想不断重复：\n找到可并行部分； 选择分块； 把块映射到硬件； 把数据放到合适存储层； 安排通信和同步； 用并发隐藏不可避免的延迟； 测量瓶颈并迭代。 26. 为什么矩阵乘法贯穿整个课程 Transformer 的大量计算最终落在 GEMM 或 GEMM-like Kernel 上，包括：\nQ/K/V 线性投影； attention 中的 QKᵀ 和 PV； MLP 的上投影、门控、下投影； 训练中的梯度计算； 张量并行下的分片矩阵乘。 矩阵乘法同时包含：\n大量独立输出元素； 沿 K 维的归约； 明确的数据复用； 对布局、带宽、缓存和专用矩阵单元的强依赖。 因此它是学习并行化、tiling、memory hierarchy 和 pipeline 的最佳样例。\n第五部分：课程之外必须补上的性能模型 27. Amdahl 定律：串行部分决定强扩展上限 若程序中可并行比例为 p，使用 N 个处理单元，理想加速比为：\nSpeedup(N) = 1 / ((1 - p) + p / N) 例如 95% 可并行，即使设备无限多，最大加速也只有 1 / 0.05 = 20 倍。\n现实还要加上通信、同步和调度开销，所以实际更低。它解释了为什么优化最后一点串行瓶颈可能比继续加卡更重要。\n28. Strong Scaling 与 Weak Scaling Strong scaling：总问题规模不变，增加设备，看运行时间能否下降。 Weak scaling：每台设备的工作量不变，设备与总问题规模一起增长，看总时间能否保持。 训练一个固定模型更接近 strong scaling；随着 GPU 数增加同时增大 batch 或模型规模，则包含 weak scaling 的味道。\n29. Arithmetic Intensity 与 Roofline 算术强度：\nArithmetic Intensity = 运算次数 FLOPs / 从慢速内存搬运的字节数 Bytes 粗略性能上限：\nPerformance ≤ min(Peak Compute, Memory Bandwidth × Arithmetic Intensity) 算术强度低：通常 memory-bound； 算术强度高：可能 compute-bound。 向量加法每个元素只做一次加法，却要读两个输入、写一个输出，通常是 memory-bound。分块矩阵乘法可让载入的数据被多次复用，算术强度高得多，更容易接近计算峰值。\n这比\u0026quot;看到 GPU 峰值 FLOPS 就估算程序速度\u0026quot;可靠得多。\n30. Little 定律式的延迟隐藏直觉 要维持高吞吐，系统中需要足够多的在途工作：\n并发在途量 ≈ 吞吐量 × 单次延迟 显存延迟很高时，需要许多 warp 在途；推理服务中单请求生成有等待时，需要 continuous batching 等调度策略维持设备利用率。底层 GPU 调度和上层服务调度，在这里有相似的结构。\n31. 数值精度不是附属问题 GPU 的不同单元和编译选项可能在速度与精度之间取舍：\nFP32、TF32、FP16、BF16、FP8 等格式吞吐不同； 累加精度可能高于输入精度； --use_fast_math 等选项可能使用更快但精度/语义不同的近似； 并行 reduction 改变求和顺序，浮点结果可能与串行结果略有不同。 性能优化必须同时定义正确性容忍度：绝对误差、相对误差、ULP、任务指标或训练稳定性。\n第六部分：容易误解的课堂表述 32. 纠偏清单 \u0026ldquo;GPU 比 CPU 快\u0026rdquo; 只对适合高吞吐并行、具有足够工作量和良好数据访问的任务成立。\n\u0026ldquo;有 N 个核心就快 N 倍\u0026rdquo; 这是无依赖、无通信、无调度开销、负载完全均衡时的理想上限。\n\u0026ldquo;一个 warp 的 32 个线程必须一直执行相同指令\u0026rdquo; 它们可以发生分支，但不同路径通常会被分阶段执行，造成发散开销。\n\u0026ldquo;block 之间绝对不能通信\u0026rdquo; 普通 kernel 的可扩展模型要求 block 独立；现代 CUDA 有 cooperative groups、cluster 等受约束机制，但不能默认依赖任意 block 的调度顺序。\n\u0026ldquo;Shared memory 一定比 global memory 快\u0026rdquo; 通常低延迟且可控，但 bank conflict、额外同步、低复用或过高资源占用都可能抵消收益。\n\u0026ldquo;Occupancy 越高越好\u0026rdquo; 需要\u0026quot;足够高\u0026quot;来隐藏延迟，不必盲目追求 100%。\n\u0026ldquo;多个 stream 就一定并发\u0026rdquo; stream 只提供并发机会；真实重叠取决于依赖和硬件资源。\n\u0026ldquo;Unified Memory 不需要考虑拷贝\u0026rdquo; API 简化了，但迁移、缺页和驻留成本仍然存在。\n\u0026ldquo;Triton 看不到生成代码\u0026rdquo; 可检查编译中间表示和生成代码；真正的限制是控制较间接、调优与定位成本可能较高。\n\u0026ldquo;torch.compile 就是 CUDA Graph\u0026rdquo; 计算图捕获、编译优化、代码生成和 CUDA Graphs 是不同环节，可能组合使用。\n\u0026ldquo;TileLang 一定跨平台且性能都好\u0026rdquo; 可移植抽象能减少迁移成本，但新后端仍需要编译器支持、调度适配和性能验证。\n第七部分：术语速查 术语 一句话解释 HPC High Performance Computing，高性能计算 AI Infra 支撑 AI 训练、推理和部署的软硬件系统 Kernel 在加速器上执行的一段计算函数 Host / Device 通常分别指 CPU 侧 / GPU 侧 Grid 一次 CUDA kernel launch 的全部 block Block / CTA 可在同一 SM 上合作、共享 shared memory 的线程组 Warp NVIDIA GPU 的线程调度/执行分组，通常含 32 个线程 SM Streaming Multiprocessor，执行 block/warp 的硬件单元 SIMT Single Instruction, Multiple Threads SIMD Single Instruction, Multiple Data SPMD Single Program, Multiple Data Tile 数据张量的一个分块 Tiling 把大计算分成适合并行和存储层次的小块 Coalescing 合并同一 warp 的连续内存请求 Divergence 同一 warp 的线程走不同控制路径 Occupancy SM 实际驻留 warp 与架构上限之比 Register pressure 每线程寄存器需求对驻留并发度造成的压力 Spill 寄存器不足，变量被放到更慢的 local/device memory Reduction 把多个值按加、最大值等操作聚合 Collective 多进程/多设备共同参与的通信操作 DSL Domain-Specific Language，面向特定领域的语言 PTX / SASS NVIDIA 的虚拟指令集 / 更接近实际 GPU 机器指令的表示 HBM High Bandwidth Memory，高带宽显存 Arithmetic Intensity 每搬运一字节数据能完成多少运算 第八部分：推荐的权威延伸资料 NVIDIA CUDA Programming Guide：Programming Model 优先读 thread block、grid、SM、block 独立性和内存层次。\nNVIDIA CUDA Programming Guide：Introduction to CUDA C++ 对照课堂中的 kernel、内置索引和 thread block cluster。\nTriton 官方教程总览 推荐依次做 vector add、fused softmax、matrix multiplication。\nTriton Vector Addition 用于掌握 program_id → offsets → mask → load → compute → store。\nTriton Matrix Multiplication 重点看三维分块、pointer arithmetic、L2-friendly program ordering 和 autotune。\nTileLang TileLibrary Reference 查阅 T.alloc_shared、T.Pipelined 等原语。\nPyTorch torch.compile 文档 用于区分图编译、Inductor/Triton 代码生成和 CUDA Graphs。\n结语：应该带走的不是术语，而是一个分析模板 以后看到任何 AI Infra 性能问题，可以按这个顺序问：\n计算是什么？ 输入、输出、规则分别是什么？ 并行机会在哪里？ 哪些任务没有依赖？ 怎样分块？ 按数据、功能、模型层、序列还是专家？ 映射到哪里？ thread、warp、CTA、SM、GPU、节点分别承担什么？ 数据在哪里？ register、shared、HBM 还是远端 GPU？ 瓶颈是什么？ 计算、带宽、延迟、同步、通信还是 launch overhead？ 能否重叠？ 搬运与计算、通信与计算、不同请求是否可流水化？ 怎样证明？ 正确性测试、benchmark 和 profiler 是否支持你的判断？ 当你能够沿这八个问题分析一个 Kernel 或一个分布式系统时，就已经不再只是\u0026quot;知道几个 GPU 术语\u0026quot;，而是在用 Infra 的方式思考。\n","date":"2026-07-25T00:00:00+08:00","image":"/MyBlog/p/ai-infra-parallel-computing-cuda-triton-tilelang/cover.svg","permalink":"/MyBlog/p/ai-infra-parallel-computing-cuda-triton-tilelang/","title":"AI Infra 讲座：从并行计算到 CUDA、Triton 与 TileLang"},{"content":"承接 Go 项目反推：Feed 流系统实战——请求生命周期，这一篇继续反推 feedsystem_video_go 的认证代码，梳理 JWT、Refresh Token、Token 撤销，以及软硬鉴权中间件之间的关系。\n一、jwt.go 文件结构 文件位置：internal/auth/jwt.go\njwtSecret() → 获取密钥 Claims → Token 里存什么 GenerateToken() → 生成 accessToken（JWT 格式，15 分钟） GenerateRefreshToken() → 生成 refreshToken（随机字符串） ParseToken() → 验证 Token（签名 + 过期） 二、密钥与 Claims jwtSecret()：获取密钥 优先使用环境变量 JWT_SECRET，没有就随机生成：\nJWT_SECRET=*** go run ./cmd 如果没有设置环境变量，每次服务器启动时密钥都会改变，之前签发的 Token 将全部失效，所有用户都要重新登录。因此正式环境必须设置稳定且足够随机的 JWT_SECRET。\nClaims：Token 里存什么 type Claims struct { AccountID uint // 用户 ID Username string // 用户名 jwt.RegisteredClaims // 标准字段（过期时间、签发时间等） } 可以把 Claims 类比为一张有有效期的身份证，上面记录用户名、用户 ID、签发时间和过期时间。\n需要注意：JWT Payload 只是经过 Base64URL 编码，并不是加密，客户端也能读取其中的内容。因此 Claims 里不能放密码、密钥等敏感数据。\n三、生成 Token GenerateToken()：生成 accessToken 1. 创建 Claims，写入用户 ID、用户名和 15 分钟后的过期时间 2. 使用 HS256 算法创建 Token 对象 3. 使用 JWT Secret 签名，生成 Token 字符串 结果是一串类似 eyJhbG...xxxx 的 JWT。它内部包含经过编码的 Header 和 Payload，以及用于防止内容被篡改的签名。\nHS256 只负责签名和验签，不负责加密。其他人虽然可以读取 Payload，但没有 JWT_SECRET 就无法生成有效签名。\nGenerateRefreshToken()：生成 refreshToken Refresh Token 和 Access Token 完全不同：\naccessToken → JWT 格式，携带 Claims，并使用密钥签名 refreshToken → 随机字符串，本身不携带用户信息 项目使用 32 个随机字节，再转换为 64 位十六进制字符串。Refresh Token 不需要携带 Claims，它只需要成为一个难以猜中的长期凭证。\n双 Token 时间设定 accessToken 15 分钟：泄露后的可利用时间较短 refreshToken 7 天： 用于换取新的 Access Token，减少重复登录 Access Token 有效期短，降低泄露风险；Refresh Token 有效期长，保证使用体验。二者配合，在安全性和便利性之间取得平衡。\n四、验证 Token ParseToken()：验证 Token 输入：客户端发送的 Token 字符串 输出：Claims（用户 ID、用户名）或错误 流程：\n1. 解析 Token 2. 确认签名算法是 HS256 3. 使用 JWT Secret 验证签名 4. 检查 Token 是否过期 5. 全部通过 → 返回 Claims 6. 任一失败 → 返回错误 这里不是“解密 Token”，因为 JWT Payload 没有被加密。服务器做的是解析内容并验证签名。\n还需要注意：ParseToken() 只检查格式、签名和过期时间，不检查 Token 是否已被撤销。\n五、Refresh 流程 Access Token 过期后，可以使用 Refresh Token 换取新的 Access Token：\n客户端：Access Token 过期，这是 Refresh Token，请签发新的 Access Token 服务器： 1. 使用 Refresh Token 查询 Redis 中的 refresh:\u0026lt;token\u0026gt; 2. 查询成功后得到 accountID 3. 查询数据库，确认 Refresh Token 与账户记录匹配 4. 生成新的 Access Token 5. 将新 Token 写入数据库和 Redis 6. 返回给客户端 可以类比为：短期门禁卡过期后，使用长期凭证到前台换一张新卡。\n关键点是：使用 Refresh Token 换取 Access Token，而不是反过来。\n六、Token 撤销 JWT 的签名和过期时间一旦确定，单靠 ParseToken() 无法让一个尚未过期的 Token 提前失效。因此项目额外在 Redis 和 MySQL 中保存当前有效 Token。\n登出时删除旧 Token：\n删除 Redis 中的三个 Key： account:\u0026lt;id\u0026gt; → accessToken account:\u0026lt;id\u0026gt;:refresh → refreshToken refresh:\u0026lt;token\u0026gt; → accountID 清空 MySQL 中的字段： token refresh_token 删除后，即使旧 JWT 的签名正确且尚未过期，中间件也无法在 Redis 或 MySQL 中匹配到它，因此会拒绝请求。\n修改密码时也可以使用相同思路：\n清空旧 Token → 签发新 Token → 让其他设备上的旧登录状态立即失效 七、JWTAuth 中间件 文件位置：internal/middleware/jwt/jwt.go\n处理流程：\n1. 从请求头读取 Authorization 2. 检查格式是否为 Bearer \u0026lt;token\u0026gt; 3. 调用 auth.ParseToken() 验证格式、签名和过期时间 4. 调用 check() 检查 Token 是否仍是当前有效 Token 5. 验证通过后将用户信息写入 Gin Context 客户端发送：\nAuthorization: Bearer *** check()：为什么同时查询 Redis 和 MySQL 第一步：查询 Redis 查到 → 对比请求 Token 是否为当前有效 Token 相同 → 放行 不同 → 拒绝，Token 已被替换或撤销 Redis 未命中或不可用 → 查询 MySQL 第二步：查询 MySQL 查到账户 → 对比数据库保存的 Token 相同 → 放行，并回填 Redis 不同 → 拒绝 Redis 是缓存，速度快但可能未命中或不可用；MySQL 是持久化的数据来源，可以作为兜底。MySQL 本身也可能发生故障，因此更准确的说法是“Redis 失败时降级查询 MySQL”，而不是“MySQL 永远可用”。\ncheck() 主要完成两件事：\n将请求中的 Token 与服务器保存的当前 Token 对比，判断是否已被撤销 将用户信息写入 gin.Context，方便后续 Handler 使用 八、两种鉴权 JWTAuth（硬鉴权）： 没有 Token 或 Token 无效 → 拒绝请求 SoftJWTAuth（软鉴权）： 没有 Token → 继续放行 有 Token且有效 → 识别用户身份后放行 项目中的使用示例：\nJWTAuth（必须登录）： /logout /rename /uploadAvatar SoftJWTAuth（不强制登录）： /feed/listLatest /feed/listByPopularity 用户没有 Token 也能浏览 Feed；携带有效 Token 时，系统可以识别用户并提供个性化结果。\n可以类比为：\nJWTAuth → 会员俱乐部，没有会员卡不能进入 SoftJWTAuth → 商场，没有会员卡也能进入，有会员卡则能获得个性化服务 九、面试要点 JWT 如何生成：Claims + HS256 + JWT Secret 签名 JWT 不等于加密：Payload 可以读取，签名用于防篡改 双 Token：短期 Access Token + 长期 Refresh Token Refresh 流程：使用 Refresh Token 换取新的 Access Token Token 撤销：清理 Redis 与 MySQL 中保存的 Token，使旧 JWT 立即失效 中间件 check()：优先查询 Redis，未命中或不可用时查询 MySQL 两种鉴权：JWTAuth 强制登录，SoftJWTAuth 允许匿名访问 这一套设计实际上让 JWT 从“完全无状态”变成了“带服务端状态的认证方案”。它牺牲了一部分无状态带来的简单扩展能力，换取了主动登出、修改密码后强制失效和单会话控制等能力。\n","date":"2026-07-25T00:00:00+08:00","image":"/MyBlog/p/go-feed-authentication/cover.svg","permalink":"/MyBlog/p/go-feed-authentication/","title":"Go 项目反推：Feed 流系统实战——认证体系"},{"content":"一、学习目标 梯度下降如何更新参数 $w$ 和 $b$？ 导数项为什么能指引下降方向？ 学习率 $\\alpha$ 应该如何理解和选择？ 如何用梯度下降训练一元线性回归模型？ 二、梯度下降算法 梯度下降通过不断调整模型参数，使成本函数 $J(w,b)$ 尽可能小。\n参数更新公式为：\n$$w := w - \\alpha \\frac{\\partial J(w,b)}{\\partial w}$$$$b := b - \\alpha \\frac{\\partial J(w,b)}{\\partial b}$$不断重复以上更新，直到算法收敛。\n符号含义 符号 含义 $w, b$ 模型参数 $J(w,b)$ 成本函数，衡量预测误差 $\\alpha$ 学习率，控制每次更新的步长 $\\frac{\\partial J}{\\partial w}$ 成本函数相对于 $w$ 的偏导数 $\\frac{\\partial J}{\\partial b}$ 成本函数相对于 $b$ 的偏导数 $:=$ 赋值，用右侧结果更新左侧变量 赋值与相等的区别 $w := w - \\alpha \\frac{\\partial J}{\\partial w}$ 表示“计算右边的结果，然后把结果存入 $w$”，而不是断言左右两边在数学上永远相等。\n在 Python 中，= 是赋值，== 是相等性判断。\n三、为什么必须同时更新 $w$ 和 $b$ 梯度下降要求两个参数基于同一组旧参数进行更新。\n正确做法：\ntemp_w = w - alpha * dj_dw # dj_dw 和 dj_db 都根据更新前的 w, b 计算 temp_b = b - alpha * dj_db w = temp_w b = temp_b 不正确的顺序更新：\ntemp_w = w - alpha * dj_dw w = temp_w # w 已被更新 temp_b = b - alpha * dj_db # 此时计算用的是新的 w，不是旧的 b = temp_b 问题在于：计算新 $b$ 时，$w$ 已经被更新，两个参数不再基于同一个旧状态进行计算。标准梯度下降所指的是同步更新。\n四、导数项的直观含义 为了理解导数，暂时只考虑一个参数：\n$$w := w - \\alpha \\frac{dJ(w)}{dw}$$导数表示成本曲线在当前位置的斜率。\n导数为正 $$\\frac{dJ}{dw} \u003e 0 \\implies w_{\\text{new}} = w - \\text{正数}$$$w$ 变小，在图像上向左移动。当位于最低点右侧时，向左移动能够降低成本。\n导数为负 $$\\frac{dJ}{dw} \u003c 0 \\implies w_{\\text{new}} = w - \\alpha(\\text{负数}) = w + \\text{正数}$$$w$ 变大，在图像上向右移动。当位于最低点左侧时，向右移动能够降低成本。\n导数为零 在局部最小值处，$\\frac{dJ}{dw} = 0$，于是 $w_{\\text{new}} = w$，参数保持不变。梯度下降到达最低点后会自然停止移动。\n核心直觉 梯度下降之所以使用“减去导数”，是因为导数指向函数增长最快的方向，而导数的反方向能够降低成本。\n五、学习率 $\\alpha$ 学习率控制每次参数更新的步长，通常取较小的正数，例如 $\\alpha = 0.01$。\n学习率太小 每次更新的步长很小 成本通常仍会下降，但需要大量迭代 训练速度非常慢 学习率太大 参数可能跨过最低点 成本可能反而增加 参数可能在最低点两侧来回震荡 算法可能无法收敛，甚至发散 为什么固定学习率也能收敛 当参数逐渐接近最低点时，成本函数的斜率会越来越小：$\\left|\\frac{dJ}{dw}\\right| \\to 0$\n即使学习率 $\\alpha$ 保持不变，实际更新量 $\\alpha \\frac{dJ}{dw}$ 也会自然变小。因此梯度下降通常表现为：\n离最低点较远时步长较大 接近最低点时步长逐渐减小 最终停留在最低点附近 六、一元线性回归模型 模型为：\n$$f_{w,b}(x) = wx + b$$其中 $x$ 是输入特征（如房屋面积），$f_{w,b}(x)$ 是预测值（如预测房价），$w$ 是斜率，$b$ 是截距。\n七、平方误差成本函数 使用 $m$ 个训练样本时，成本函数为：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)^2$$其中 $x^{(i)}$ 是第 $i$ 个样本的输入，$y^{(i)}$ 是真实值，$m$ 是训练样本数量。\n前面的 $\\frac{1}{2}$ 是为了在求导时抵消平方项产生的系数 2，使梯度公式更简洁。\n八、线性回归的梯度公式 成本函数相对于 $w$ 的偏导数：\n$$\\frac{\\partial J(w,b)}{\\partial w} = \\frac{1}{m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right) x^{(i)}$$成本函数相对于 $b$ 的偏导数：\n$$\\frac{\\partial J(w,b)}{\\partial b} = \\frac{1}{m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)$$两者的区别：$w$ 的导数末尾需要乘 $x^{(i)}$，$b$ 的导数不需要。\n九、完整的梯度下降算法 每轮迭代先计算：\n$$d_w = \\frac{1}{m} \\sum_{i=1}^{m} \\left(wx^{(i)} + b - y^{(i)}\\right) x^{(i)}$$$$d_b = \\frac{1}{m} \\sum_{i=1}^{m} \\left(wx^{(i)} + b - y^{(i)}\\right)$$然后同步更新：\n$$w := w - \\alpha \\, d_w$$$$b := b - \\alpha \\, d_b$$重复以上过程，直到成本不再明显下降，或者参数变化已经非常小。\n十、收敛、局部最小值与全局最小值 收敛 梯度下降“收敛”通常表示：成本函数不再明显下降，$w$ 和 $b$ 的变化越来越小，参数已经接近某个最小值。\n局部最小值 vs 全局最小值 局部最小值：某个点比附近其他点都低，但不一定是整个函数的最低点 全局最小值：在所有可能的参数取值中，成本函数值最低的点 线性回归的优势 线性回归的平方误差成本函数是凸函数（碗形函数），具有以下性质：\n只有一个全局最小值 不存在多个不同的局部最小值 只要学习率选择合理，梯度下降就能收敛到全局最小值 十一、批量梯度下降 上述算法属于批量梯度下降（Batch Gradient Descent）。“批量”表示每次更新参数时都使用全部 $m$ 个训练样本（$\\sum_{i=1}^{m}$），即每轮更新都会遍历整个训练集。\n其他梯度下降方法可能每次只使用一个训练样本或一小批训练样本，但当前的一元线性回归使用的是完整训练集。\n十二、Python 实现 计算梯度 def compute_gradient(x, y, w, b): m = x.shape[0] dj_dw = 0 dj_db = 0 for i in range(m): f_wb = w * x[i] + b # 预测值 error = f_wb - y[i] # 预测误差 dj_dw += error * x[i] # w 的梯度需要乘 x[i] dj_db += error # b 的梯度不需要乘 x[i] dj_dw /= m dj_db /= m return dj_dw, dj_db 计算成本 def compute_cost(x, y, w, b): m = x.shape[0] cost = 0 for i in range(m): f_wb = w * x[i] + b cost += (f_wb - y[i]) ** 2 return cost / (2 * m) 梯度下降主函数 def gradient_descent(x, y, w_in, b_in, alpha, num_iters, cost_function, gradient_function): J_history = [] p_history = [] w = w_in b = b_in for i in range(num_iters): # 先计算两个梯度（基于当前的 w, b） dj_dw, dj_db = gradient_function(x, y, w, b) # 同步更新参数 w = w - alpha * dj_dw b = b - alpha * dj_db # 保存代价和参数（用于可视化） if i \u0026lt; 100000: J_history.append(cost_function(x, y, w, b)) p_history.append([w, b]) # 定期输出训练过程 if i % math.ceil(num_iters / 10) == 0: print(f\u0026#34;Iteration {i:4}: Cost {J_history[-1]:0.2e} \u0026#34; f\u0026#34;w: {w:0.3e}, b: {b:0.5e}\u0026#34;) return w, b, J_history, p_history 返回值：训练后的 w、b，每次迭代的代价 J_history，每次迭代的参数 p_history。\n十三、运行实验 训练数据 x_train = np.array([1.0, 2.0]) # 房屋面积（千平方英尺） y_train = np.array([300.0, 500.0]) # 房屋售价（千美元） 训练参数 w_init = 0 b_init = 0 iterations = 10000 alpha = 1.0e-2 运行 w_final, b_final, J_hist, p_hist = gradient_descent( x_train, y_train, w_init, b_init, alpha, iterations, compute_cost, compute_gradient ) 最终结果约为 $w \\approx 199.99$，$b \\approx 100.01$，即模型为 $f(x) = 200x + 100$。\n梯度下降的收敛特点 成功运行时可以观察到：\n代价在开始阶段迅速下降 dj_dw 和 dj_db 的绝对值逐渐减小 越接近最低点，梯度越小 梯度变小后，参数更新速度也会变慢 代价应持续下降并逐渐稳定 虽然学习率 $\\alpha$ 保持不变，但实际更新量 = $\\alpha \\times$ 梯度，梯度变小时更新幅度自然变小。\n十四、可视化 代价变化曲线 fig, (ax1, ax2) = plt.subplots(1, 2, constrained_layout=True, figsize=(12, 4)) ax1.plot(J_hist[:100]) # 训练初期：代价下降很快 ax2.plot(1000 + np.arange(len(J_hist[1000:])), J_hist[1000:]) # 训练后期：下降较慢 ax1.set_title(\u0026#34;Cost vs. iteration (start)\u0026#34;) ax2.set_title(\u0026#34;Cost vs. iteration (end)\u0026#34;) ax1.set_ylabel(\u0026#34;Cost\u0026#34;) ax2.set_ylabel(\u0026#34;Cost\u0026#34;) ax1.set_xlabel(\u0026#34;Iteration step\u0026#34;) ax2.set_xlabel(\u0026#34;Iteration step\u0026#34;) plt.show() 分开绘制是因为训练初期和后期下降速度差异大，用不同范围可以更清楚地观察变化。\n梯度下降路径（等高线图） fig, ax = plt.subplots(1, 1, figsize=(12, 6)) plt_contour_wgrad(x_train, y_train, p_hist, ax) plt.show() 等高线代表不同的代价值，箭头代表参数 $(w, b)$ 的更新路径。可以观察到：参数不断向最低点移动，开始时梯度大步幅也大，接近最低点时梯度变小步幅缩短。\n十五、模型预测 训练完成后，使用 $\\hat{y} = w_{\\text{final}} x + b_{\\text{final}}$ 进行预测：\nprint(f\u0026#34;1000 sqft: {w_final * 1.0 + b_final:.1f} Thousand dollars\u0026#34;) print(f\u0026#34;1200 sqft: {w_final * 1.2 + b_final:.1f} Thousand dollars\u0026#34;) print(f\u0026#34;2000 sqft: {w_final * 2.0 + b_final:.1f} Thousand dollars\u0026#34;) 房屋面积 预测售价 1000 平方英尺 300 千美元 1200 平方英尺 340 千美元 2000 平方英尺 500 千美元 虽然 1200 平方英尺不在训练数据中，模型仍可以根据学到的线性关系进行预测。\n十六、学习率的影响 合适的学习率 代价持续下降，参数逐渐靠近最优值，算法最终收敛 学习率过小 更新步幅很小，算法能够稳定收敛，但训练速度较慢 学习率过大 参数可能跨过最低点，$w$ 和 $b$ 在正负之间振荡 梯度符号反复变化，参数绝对值越来越大 代价不断上升，算法最终发散 判断学习率过大的典型信号：\n代价不降反升 参数绝对值越来越大 梯度不断改变符号 参数在最低点两侧大幅振荡 例如将学习率改为 0.8，只跑 10 次迭代就能观察到发散过程。\n十七、常见错误与检查方法 没有同步更新参数：应先计算新 $w$ 和新 $b$，再统一赋值 学习率过小：成本下降但极其缓慢 → 适当增大 $\\alpha$ 学习率过大：成本上下震荡或越来越大 → 减小 $\\alpha$ 导数公式漏乘 $x^{(i)}$：计算 $w$ 的梯度时必须包含 $\\text{error} \\times x^{(i)}$，$b$ 的梯度只有误差项 没有观察成本变化：训练过程中应定期计算 $J(w,b)$，正常情况下成本应总体持续下降 十八、核心知识总结 梯度下降的目标是通过调整参数来最小化成本函数 学习率 $\\alpha$ 控制每次更新的步长 导数的符号决定参数移动方向，大小影响移动幅度 $w$ 和 $b$ 必须基于同一组旧值同步更新 学习率太小导致收敛缓慢，太大导致震荡或发散 接近最低点时导数自然变小，更新步长也会变小 线性回归的平方误差成本函数是凸函数，只有一个全局最小值 批量梯度下降每次更新都会使用全部训练样本 训练完成后，使用 $f_{w,b}(x) = wx + b$ 对新数据进行预测 ","date":"2026-07-25T00:00:00+08:00","image":"/MyBlog/p/coursera-ml-gradient-descent/cover.svg","permalink":"/MyBlog/p/coursera-ml-gradient-descent/","title":"机器学习基础：用梯度下降法训练模型"},{"content":"承接 Go 项目反推：Feed 流系统实战——从 Docker 到开源贡献，这一篇继续反推 feedsystem_video_go 的后端代码。这次不再从整体架构看项目，而是以 POST /account/login 为例，追踪一个请求从进来到返回的完整路径。\n路由基础 router.go 是什么 router.go = 路由表 = “地址簿”。它告诉服务器：什么方式 + 访问哪个路径 → 转发到哪个函数。\nPOST /account/login → accountHandler.Login POST /account/register → accountHandler.CreateAccount GET /healthz → 返回 {\u0026#34;status\u0026#34;:\u0026#34;ok\u0026#34;} 类比：router.go 是前台接待（“办 login 业务找张三”），handler.go 是张三的工位（“我来处理”）。\n路由组 accountGroup := r.Group(\u0026#34;/account\u0026#34;) 创建一个路由组，前缀是 /account。组里的所有路由，路径前面自动加上前缀：\naccountGroup.POST(\u0026#34;/login\u0026#34;, ...) → 实际路径 POST /account/login accountGroup.POST(\u0026#34;/register\u0026#34;, ...) → 实际路径 POST /account/register 类比：把用户相关接口都放在 /account 这个“文件夹”下。\n路由注册 accountGroup.POST(\u0026#34;/login\u0026#34;, loginLimiter, accountHandler.Login) 三个参数：\n\u0026#34;/login\u0026#34; → 路径（拼上 /account，变成 /account/login） loginLimiter → 限流中间件（先过这一关） accountHandler.Login → 最终处理请求的函数（过了限流才到这里） 请求流程：\nPOST /account/login 进来 ↓ loginLimiter（限流中间件） ↓ accountHandler.Login（handler 处理） 函数引用 vs 函数调用 accountGroup.POST(\u0026#34;/login\u0026#34;, loginLimiter, accountHandler.Login) accountHandler.Login 是函数本身，不是调用。Go 里函数可以当变量传来传去，和 CS61A 的高阶函数一个意思：\naccountHandler.Login → 函数本身（把函数“交给”路由器，等有人请求时再调用） accountHandler.Login(c) → 调用函数（立刻执行） 类比：给你一张纸条写上张三的电话号码（函数引用） vs 现在立刻拨打电话（函数调用）。\n现在不执行，等有人发 POST /account/login 时，路由器才会调用 Login(c) 并传入请求信息。\n三层架构 结构创建 router.go 第 40-42 行：\naccountRepository := account.NewAccountRepository(db) accountService := account.NewAccountService(accountRepository, cache) accountHandler := account.NewAccountHandler(accountService) 创建三个“工人”：\nRepository → 操作数据库的人（查表、写表） Service → 处理业务逻辑的人（校验密码、生成 Token） Handler → 接收请求的人（解析参数、返回结果） 依赖关系：db → Repository → Service → Handler → 路由\n类比：先招档案员（Repository）给数据库钥匙，再招业务员（Service）管档案员，再招前台（Handler）对接业务员，最后门口挂牌子（路由）：“办 login 业务找前台”。\nHandler 结构体 type AccountHandler struct { accountService *AccountService } func NewAccountHandler(accountService *AccountService) *AccountHandler { return \u0026amp;AccountHandler{accountService: accountService} } AccountHandler 是一个结构体，里面有一个字段 accountService。NewAccountHandler 创建这个结构体并把 Service 存进去。\n分层的意义 handler 只管：解析请求 → 调用 service → 返回结果 service 处理：校验密码 → 生成 Token → 写数据库 repo 处理： 操作数据库 handler 不关心密码怎么校验、Token 怎么生成，它只管“调用 service”。\n文件目录结构 internal/http/router.go → 路由目录，负责“找谁” internal/account/handler.go → 用户模块，负责“怎么干” internal/account/service.go → 用户模块，负责“核心逻辑” internal/account/repo.go → 用户模块，负责“操作数据库” 按模块分目录：account 目录下放所有跟用户相关的代码，video 目录下放所有跟视频相关的代码。\nHandler 层：Login 函数 handler.go 第 116-133 行：\nfunc (h *AccountHandler) Login(c *gin.Context) { var req LoginRequest // 声明容器 if err := c.ShouldBindJSON(\u0026amp;req); err != nil { ... } // 解析 JSON account, err := h.accountService.FindByUsername(...) // 查数据库存不存在 accessToken, refreshToken, err := h.accountService.Login(...) // 校验密码+生成Token c.JSON(200, LoginResponse{Token, RefreshToken, ...}) // 返回给客户端 } 完整流程：\n1. 解析客户端发的 JSON（用户名、密码） 2. 去数据库查这个用户存不存在 3. 存在的话，让 service 校验密码、生成 Token 4. 把 Token 返回给客户端 ShouldBindJSON c.ShouldBindJSON(\u0026amp;req) 把客户端发的 JSON 解析到 req 变量里。客户端发 {\u0026quot;username\u0026quot;: \u0026quot;zhangsan\u0026quot;, \u0026quot;password\u0026quot;: \u0026quot;***\u0026quot;}，解析后 req.Username = \u0026quot;zhangsan\u0026quot;，req.Password = \u0026quot;123456\u0026quot;。\nShouldBindJSON vs json.NewDecoder：两个干的事一样，都是把 JSON 解析到变量里。c.ShouldBindJSON(\u0026amp;req) 是 Gin 封装的一行搞定；json.NewDecoder(r.Body).Decode(\u0026amp;req) 是原生写法，要三行还要检查 err。类比：自动挡 vs 手动挡。\nService 层：Login 函数 handler 调用 service 的 Login：\naccessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password) service.go 的 Login 做了 6 件事：\n1. 查数据库拿用户信息（FindByUsername） 2. bcrypt 校验密码 3. 生成 accessToken 和 refreshToken 4. Token 存进 MySQL（调用 repo.Login） 5. Token 存进 Redis（3 个 key） 6. 返回两个 Token ctx（context） ctx = 请求的“身份证”，带着两个信息：\n超时时间：这个请求最多等多久 取消信号：客户端断开了，别再查了 每一层都要传 ctx，这样整条链路都能感知到“客户端还在不在”。\n类比：你去饭店点菜，等了 5 分钟走了。没有 ctx → 厨师还在炒，炒完发现你走了，菜倒掉。有 ctx → 服务员告诉厨师“客人走了”，厨师立刻停手。\nbcrypt 校验密码 bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) 传入两个参数：数据库里存的加密密码（哈希）和用户这次输入的明文密码。bcrypt 把明文密码加密，和数据库里的对比，一样返回 nil（成功），不一样返回 error（失败）。\n为什么不存明文？数据库被黑了，黑客拿到的是加密字符串，反推不出原始密码。\n[]byte() = 把字符串转成字节数组，bcrypt 只接收这种格式。\n双 Token（JWT） 登录成功返回两个 Token：\naccessToken → 短期有效（15分钟），用来证明“我是谁” refreshToken → 长期有效（7天），用来续期 accessToken 为什么两个？只用一个 token 的问题：设太短 → 用户每 15 分钟重新登录，体验差；设太长 → 被偷了就一直能用，不安全。双 token 解决了这个问题：accessToken 短，被偷了也很快过期；refreshToken 长，用户不用频繁登录；accessToken 过期了，用 refreshToken 换新的。\n类比：accessToken = 门禁卡（每天过期，重新刷卡续期），refreshToken = 身份证（长期有效，门禁卡丢了拿身份证补办）。\nToken 根据用户信息生成（不是纯随机）：\naccessToken → 包含用户ID + 用户名 + 过期时间（15分钟） refreshToken → 只包含用户ID + 过期时间（7天） 用密钥加密生成一串乱码字符串 为什么有 ID 又有用户名 ID（account.ID） → 数据库主键，不可变，用来精确查找 用户名（username）→ 可以改，用来方便识别 类比：身份证号（ID）不可变，查账户用；姓名（username）可以改，写在凭证上让人看。生成 Token 时两个都存：ID 用来查人，用户名用来识别。\nRepo 层：Login 函数 service 调用 repo 把 Token 存进 MySQL：\nas.accountRepository.Login(ctx, account.ID, accessToken, refreshToken) repo.go 里用 GORM 操作数据库：\nar.db.WithContext(ctx).Model(\u0026amp;Account{}).Where(\u0026#34;id = ?\u0026#34;, id).Updates( map[string]interface{}{\u0026#34;token\u0026#34;: token, \u0026#34;refresh_token\u0026#34;: refreshToken}) 翻译：在 account 表里，找到 id 等于 id 的那一行，更新 token 和 refresh_token。\n等价 SQL：\nUPDATE account SET token = \u0026#39;xxx\u0026#39;, refresh_token = \u0026#39;yyy\u0026#39; WHERE id = 1; GORM = 用 Go 代码描述“我要干什么”，GORM 帮你翻译成 SQL。\nRedis 缓存 登录成功后，Token 存两份：\nMySQL 存一份 → 保险，重启不丢，但慢 Redis 存一份 → 快，验证时直接查这里 Redis 存了 3 个 key：\nkey: \u0026#34;account:1\u0026#34; → accessToken TTL 24小时 key: \u0026#34;account:1:refresh\u0026#34; → refreshToken TTL 7天 key: \u0026#34;refresh:xxxxx\u0026#34; → accountID TTL 7天 为什么存 3 个？第 1 个验证 Token 时查，第 2 个刷新 Token 时查，第 3 个用 refreshToken 反查用户 ID。\nRedis 不可用时降级：跳过缓存，只靠 MySQL，功能不丢，只是慢一点。\n中间件 怎么执行的 中间件 = 挡在 handler 前面的一道关卡。两个关键函数：\nc.Next() → 放行，让请求继续走到下一个中间件或 handler c.Abort() → 拦截，不让请求继续走，handler 不会执行 类比：c.Next() = 安检通过，放你进去；c.Abort() = 安检不过，拦住你。\n为什么是两层函数 func Limit(cache, name string, ...) gin.HandlerFunc { // 外层：传配置 return func(c *gin.Context) { // 内层：处理请求 c.Next() } } 外层传配置，内层处理请求。外层函数执行一次，创建中间件；内层函数每次有人访问都执行一次。\n类比：外层 = 装修奶茶店（执行一次，买机器、买原料）；内层 = 每天营业（执行无数次，客人来了做奶茶）。\ngin.HandlerFunc = Gin 要求中间件必须是这种格式的函数。\ngin.Context 的数据传递 中间件把用户 ID 塞进 c 里：\nc.Set(\u0026#34;accountID\u0026#34;, accountID) // 中间件：往 c 里存 handler 从 c 里取出来：\nvalue, exists := c.Get(\u0026#34;accountID\u0026#34;) // handler：从 c 里取 c 就是一个“传话筒”，中间件往里塞东西，handler 从里拿东西。c 是临时对象，只活在一次请求里，请求结束就没了，不会存到 Redis。\n类比：安检员检查完证件，在你手上盖个章“1号”（中间件 c.Set）；柜员看你手上的章，知道你是“1号”（handler c.Get）。\n错误到 HTTP 状态码的映射 ClassifyHTTPStatus 就是一张对照表：\nerror 类型 → 状态码 → 含义 nil（没错误） → 200 → 成功 ErrUnauthorized（未登录） → 401 → 未授权 ErrValidation（参数错误） → 400 → 请求格式错 gorm.ErrRecordNotFound（查不到）→ 404 → 资源不存在 其他错误 → 500 → 服务器内部错误 handler 里用法：\nc.JSON(apierror.ClassifyHTTPStatus(err), gin.H{\u0026#34;error\u0026#34;: err.Error()}) 翻译：看一眼 error 是什么类型，返回对应的状态码。\nLogin 请求完整调用链 1. 用户发 POST /account/login 2. router.go 找到这条路由，先过 loginLimiter（限流中间件） 3. 进入 handler.go 的 Login 函数： - 解析 JSON（拿到 username 和 password） - 调用 service.FindByUsername（查用户存不存在） - 调用 service.Login（校验密码 + 生成 Token） 4. 进入 service.go 的 Login 函数： - 再查一次数据库（拿到密码哈希） - bcrypt 校验密码 - 生成 accessToken 和 refreshToken - 调用 repo.Login（把 Token 存 MySQL） - 把 Token 存进 Redis（3 个 key） 5. 进入 repo.go 的 Login 函数： - 用 GORM 把 Token 写进 account 表 6. Token 一层一层返回：repo → service → handler → 客户端 涉及 4 个文件：\nrouter.go → 注册路由，把路径和函数连起来 handler.go → 解析请求，调用 service service.go → 校验密码，生成 Token，存数据库和缓存 repo.go → 操作 MySQL 涉及 2 个存储：\nMySQL → 存用户信息和 Token（持久化，重启不丢） Redis → 缓存 Token（快，重启会丢） ","date":"2026-07-21T00:00:00+08:00","image":"/MyBlog/p/go-feed-request-lifecycle/cover.svg","permalink":"/MyBlog/p/go-feed-request-lifecycle/","title":"Go 项目反推：Feed 流系统实战——请求生命周期"},{"content":"线性回归是机器学习里最适合入门的模型之一：它足够简单，可以把输入、预测、参数、成本函数这些核心概念讲清楚；它也足够重要，因为后续更复杂的模型训练，仍然离不开这些基本思想。本篇整理 Coursera 机器学习课程中的线性回归内容，从房价预测案例出发，梳理模型函数、训练数据、成本函数和梯度下降之间的关系。\n线性回归的基本概念 线性回归（Linear Regression） 是一种监督学习模型，通过为数据拟合一条直线来预测连续数值。\n以房价预测为例：\n输入 x：房屋面积 输出 y：房屋价格 模型：一条尽可能贴合训练数据的直线 可将模型表示为：\n$$\\hat{y} = f_{w,b}(x) = wx + b$$其中：\n$x$：输入特征，例如房屋面积 $\\hat{y}$：模型预测的房屋价格 $w$：直线的斜率 $b$：直线的截距 因为只有一个输入特征，这种模型称为单变量线性回归（Univariate Linear Regression）。“单变量”表示模型只有一个输入变量，而不是只有一个参数，该模型仍然包含两个参数 $w$ 和 $b$。\n以后还可以使用多个输入特征预测房价，例如房屋面积、卧室数量、浴室数量、房屋年龄、地理位置等，属于多变量或多特征线性回归。\n训练集与符号约定 训练集 用于训练模型的数据称为训练集（Training Set）。\n房价预测的训练数据来自美国波特兰市，包含：\n房屋面积，单位为平方英尺 房屋售价，单位为千美元 同一组数据可以通过两种方式表示：\n散点图：横轴为房屋面积，纵轴为房屋价格，每个数据点对应一套已售房屋。如果训练集有 47 套房屋，图中就有 47 个数据点。\n数据表：\n房屋面积（平方英尺） 房屋价格（千美元） 2104 400 … … 表格中的每一行对应一个训练示例，也对应散点图中的一个数据点。例如面积 2104 平方英尺，售价 400 千美元，即 400,000 美元。\n符号约定 符号 含义 房价示例 $x$ 输入特征（Feature） 房屋面积 $y$ 真实值（Label / Target Variable） 房屋实际售价 $\\hat{y}$ 模型预测值 预测房价 $f_{w,b}(x)$ 参数为 $w, b$ 的模型对 $x$ 的预测 $\\hat{y} = wx + b$ $m$ 训练样本总数 47 $(x^{(i)}, y^{(i)})$ 第 $i$ 个训练样本 $(2104, 400)$ 一个完整的训练示例由输入和正确输出组成 $(x, y)$。第 $i$ 个训练示例表示为 $(x^{(i)}, y^{(i)})$，其中 $x^{(i)}$ 是第 $i$ 个样本的输入，$y^{(i)}$ 是第 $i$ 个样本的正确输出。\n需要特别注意：$x^{(2)}$ 表示“第二个训练示例的输入”，不是 $x$ 的平方。括号中的上标只是训练样本的编号。\n为什么称为监督学习 训练集中同时提供了输入（房屋面积）和正确输出（房屋实际售价）。每套已售房屋的数据都相当于向模型提供一个带答案的学习示例，因此这种学习方式称为“监督学习”。\n基本流程：\n收集包含输入和正确输出的数据 使用这些数据训练模型 模型学习输入与输出之间的规律 将新输入交给模型 模型预测相应的输出 参数 $w$ 和 $b$ 的作用 斜率 $w$ $w$ 决定直线的倾斜程度：\nw \u0026gt; 0：直线向右上方倾斜，x 增大时预测值增大 w = 0：直线是水平的，预测值不随 x 变化 w \u0026lt; 0：直线向右下方倾斜，x 增大时预测值减小 在房价问题中，$w$ 可以理解为房屋面积每增加一个单位，预测价格增加多少。$w$ 也称为权重或系数。\n截距 $b$ $b$ 决定直线与纵轴的交点。当 $x = 0$ 时：\n$$f(0) = b$$因此 $b$ 是模型在输入为零时的预测值。\n示例 当 $w = 0, b = 1.5$ 时：\n$$f(x) = 1.5$$无论输入是多少，预测结果始终为 1.5。\n当 $w = 0.5, b = 0$ 时：\n$$f(x) = 0.5x$$直线斜率为 0.5，经过原点。\n当 $w = 0.5, b = 1$ 时：\n$$f(x) = 0.5x + 1$$直线斜率仍为 0.5，但与纵轴相交于 1。\n假设 $f(x) = 0.1x + 50$，当房屋面积为 1250 平方英尺时：\n$$\\hat{y} = 0.1 \\times 1250 + 50 = 175$$预测价格为 175,000 美元。\n不同的 $w$ 和 $b$ 会形成不同的直线，也会产生不同的预测结果。\n学习算法的任务 训练线性回归模型的核心任务，是根据训练数据选择合适的 $w$ 和 $b$，使直线尽可能贴近数据点。\n完整过程可以表示为：\n$$\\{(x^{(i)}, y^{(i)})\\}_{i=1}^{m} \\longrightarrow \\text{学习算法} \\longrightarrow w, b \\longrightarrow f_{w,b}(x)$$也就是说：\n将训练数据交给学习算法 学习算法寻找合适的 $w$ 和 $b$ 得到模型 $f_{w,b}(x) = wx + b$ 用模型预测新输入对应的 $\\hat{y}$ 客户的房屋尚未出售，因此其真实价格不在训练集中。模型需要先从已售房屋的数据中学习，再预测这套房屋的价格。\n$y$ 与 $\\hat{y}$ 的区别 $y$：实际目标值 $y$ 表示训练数据中的真实答案。例如 $y = 400$ 表示某套房屋的实际售价为 400 千美元。\n$\\hat{y}$：预测值 $\\hat{y}$ 读作 “y hat”，表示模型对 $y$ 的估计：\n$$\\hat{y} = f_{w,b}(x) = wx + b$$预测值不一定等于实际值：\n$$\\hat{y} \\neq y$$例如，模型预测某套房屋售价为 220,000 美元，但只有房屋真正售出后，才能知道真实售价 $y$。\n需要明确区分：\n$$\\boxed{y = \\text{真实值}, \\qquad \\hat{y} = \\text{预测值}}$$成本函数 为什么需要成本函数 不同的 $w$ 和 $b$ 会产生不同的直线。成本函数的作用是：\n衡量一组 $w, b$ 所产生的直线，对训练数据拟合得有多好。\n成本越小，通常说明预测值越接近真实值。\n预测误差 对于第 $i$ 个训练样本 $(x^{(i)}, y^{(i)})$，模型预测值为：\n$$\\hat{y}^{(i)} = f_{w,b}(x^{(i)})$$预测误差为：\n$$\\hat{y}^{(i)} - y^{(i)} = \\text{预测值} - \\text{真实值}$$例如，模型预测价格为 280，真实价格为 300：\n$$280 - 300 = -20$$误差为 $-20$，表示模型低估了价格。\n平方误差成本函数 线性回归常用的成本函数是平方误差成本函数：\n$$\\boxed{J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(f_{w,b}(x^{(i)}) - y^{(i)}\\right)^2}$$也可以写成：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=1}^{m} \\left(\\hat{y}^{(i)} - y^{(i)}\\right)^2$$计算过程：\n计算每个样本的预测值 用预测值减去真实值 将误差平方 把所有平方误差相加 除以 $2m$ 为什么误差要平方 如果直接将误差相加，正负误差可能互相抵消：\n$$(-20) + 20 = 0$$但模型实际产生了两次误差。平方以后：\n$$(-20)^2 + 20^2 = 800$$因此：\n所有误差都会变成非负数 较大的误差会受到更明显的惩罚 成本不会因为正负抵消而错误地变小 为什么除以 $m$ 和 $2$ 除以 $m$：$m$ 是训练样本数量。除以 $m$ 相当于计算平均误差，使成本不会仅仅因为训练数据变多而自动增大。\n再除以 $2$：主要是为了让后续求导和梯度下降的公式更简洁。是否除以 $2$ 不会改变最佳参数的位置。\n成本函数的直观示例 为了方便观察，暂时令 $b = 0$，模型简化为：\n$$f_w(x) = wx$$训练集为：\n$$(1, 1), \\quad (2, 2), \\quad (3, 3)$$当 $w = 1$ 预测值为 1, 2, 3，全部等于真实值：\n$$J(1) = 0$$模型完美拟合训练数据。\n当 $w = 0.5$ 预测值为 0.5, 1, 1.5。平方误差为：\n$$(0.5-1)^2 + (1-2)^2 + (1.5-3)^2 = 0.25 + 1 + 2.25 = 3.5$$$$J(0.5) = \\frac{3.5}{2 \\times 3} \\approx 0.58$$当 $w = 0$ 所有预测值都是 0：\n$$J(0) = \\frac{1^2 + 2^2 + 3^2}{6} = \\frac{14}{6} \\approx 2.33$$当 $w = -0.5$ 直线方向与数据趋势相反：\n$$J(-0.5) = 5.25$$结果对比 $w$ $J(w)$ 拟合情况 $-0.5$ $5.25$ 很差 $0$ $2.33$ 较差 $0.5$ $0.58$ 较好 $1$ $0$ 最佳 这个例子中的最佳参数是 $w = 1$。\n模型函数与成本函数的区别 两者容易混淆，需要明确区分：\n模型函数 $f_{w,b}(x) = wx + b$ 输入是 $x$（特征），输出是预测值 $\\hat{y}$ 模型图的坐标轴：横轴 $x$（房屋面积），纵轴 $y$ 或 $\\hat{y}$（价格） 回答的问题：“这套房子预测多少钱？” 成本函数 $J(w,b)$ 输入是模型参数 $w, b$，输出是模型的成本 成本图的坐标轴是参数和成本，而不是房屋面积与价格 回答的问题：“这组模型参数整体表现有多差？” 成本函数的图像 只有参数 $w$ 时（$b = 0$） 成本函数只有一个参数 $J(w)$，通常呈 U 形曲线：\n成本 J ↑ | \\ / | \\_____/ +------------→ w 最小值 最低点对应成本最小的 $w$。\n同时使用 $w$ 和 $b$ 时 完整模型包含两个参数 $J(w,b)$，这时成本函数是一个三维曲面，通常类似碗形：\n一个方向代表 $w$ 一个方向代表 $b$ 曲面的高度代表成本 $J(w,b)$ 碗底对应成本函数的最小值，也就是最佳的 $w, b$。\n等高线图 三维成本函数也可以用二维的等高线图表示。每条椭圆线表示成本相同的一组 $w, b$：\n$$J(w_1, b_1) = J(w_2, b_2)$$可以把等高线图理解成从正上方观察碗形曲面：\n外层椭圆：成本通常较高 越靠近中心：成本通常越低 最内层椭圆中心：成本最小值附近 不同的 $w, b$ 即使成本相同，也可能对应不同的模型直线。\n训练目标与梯度下降 训练目标 线性回归最终需要解决的问题是：\n$$\\boxed{\\min_{w,b} J(w,b)}$$也就是找到一组 $w, b$，使成本函数尽可能小。\n完整逻辑：\n选择 w, b ↓ 得到直线 f(x) = wx + b ↓ 计算每个样本的预测误差 ↓ 计算成本 J(w,b) ↓ 不断调整 w, b ↓ 找到成本最小的参数 为什么需要梯度下降 理论上可以手动尝试很多组 $w, b$，计算每一组参数的成本，再选择成本最低的一组。但这种方法效率非常低，当模型参数很多时，几乎无法手动完成。\n因此需要一种能够自动调整参数、寻找成本函数最小值的算法：\n$$\\boxed{\\text{梯度下降（Gradient Descent）}}$$梯度下降不仅用于线性回归，也是训练神经网络和许多复杂人工智能模型的基础算法。\n成本函数的代码实现 def compute_cost(x, y, w, b): m = x.shape[0] # 训练样本数量 cost_sum = 0 for i in range(m): f_wb = w * x[i] + b # 第 i 个样本的预测值 cost = (f_wb - y[i]) ** 2 # 平方误差 cost_sum = cost_sum + cost # 累加 total_cost = cost_sum / (2 * m) # 计算平均成本 return total_cost 逐步理解：\nm = x.shape[0]：获取训练样本数量 cost_sum = 0：准备变量，累计所有样本的平方误差 for i in range(m)：逐个处理训练样本 f_wb = w * x[i] + b：计算预测值 $\\hat{y}^{(i)} = wx^{(i)} + b$ cost = (f_wb - y[i]) ** 2：计算预测值和真实价格的平方误差 cost_sum = cost_sum + cost：把当前样本的误差加入总误差 total_cost = cost_sum / (2 * m)：按照成本函数公式计算最终成本 对应公式：\n$$J(w,b) = \\frac{1}{2m} \\sum_{i=0}^{m-1} \\left(wx^{(i)} + b - y^{(i)}\\right)^2$$验证示例 import numpy as np x_train = np.array([1.0, 2.0]) y_train = np.array([300.0, 500.0]) w = 200 b = 100 两个预测分别是：\n$$200 \\times 1 + 100 = 300$$$$200 \\times 2 + 100 = 500$$预测值与真实值完全相同：\ncompute_cost(x_train, y_train, 200, 100) # 结果：0.0 这说明 $w = 200, b = 100$ 能完美拟合这两个数据点。\n关键理解：\ncompute_cost() 只负责评价一组 $w, b$ 好不好 成本越小，模型对训练数据的拟合通常越好 两个点可以被一条直线完美穿过，因此成本可以为零；多个不共线的数据点无法全部完美命中，因此最低成本通常大于零 为什么先学线性模型 现实中的数据关系不一定是直线，也可能是曲线、抛物线或更复杂的非线性关系。但线性模型具有以下优点：\n数学形式简单 容易理解和实现 便于观察模型参数的作用 是学习复杂机器学习模型的基础 因此，课程首先从线性函数入手，之后再扩展到非线性模型。\n","date":"2026-07-21T00:00:00+08:00","image":"/MyBlog/p/coursera-ml-linear-regression/cover.svg","permalink":"/MyBlog/p/coursera-ml-linear-regression/","title":"机器学习基础：线性回归模型"},{"content":"机器学习的任务看起来五花八门：预测房价、识别肿瘤、给新闻分组、发现异常交易。理解这些问题的第一步，是分清数据中有没有正确答案。本篇整理 Coursera 机器学习课程的基础内容，从这个区别出发，梳理监督学习与无监督学习的核心概念和典型应用。\n监督学习 监督学习（Supervised Learning）让算法学习从输入 x 到输出 y 的映射：\n$$x \\longrightarrow y$$也可以表示为：\n$$\\hat{y}=f(x)$$ x：输入或特征（Feature） y：正确答案，也称标签（Label） f：模型从数据中学习到的映射关系 \\hat{y}：模型给出的预测结果 监督学习的核心特点是：训练数据中同时包含输入和正确答案。一条训练样本可以表示为 $(x,y)$。之所以称为“监督”学习，是因为正确标签 y 为算法提供了学习目标。\n训练与预测 训练阶段向算法提供大量带标签的数据：\n$$(x^{(1)},y^{(1)}),(x^{(2)},y^{(2)}),\\ldots,(x^{(m)},y^{(m)})$$算法观察输入与输出之间的关系，学习得到模型 f。训练完成后，将模型从未见过的新输入 x 交给模型，模型根据学到的规律输出预测结果 $\\hat{y}$。\n模型的目标不是简单记住训练数据，而是对未见过的数据也能作出准确预测。这种能力称为泛化能力（Generalization）。\n典型应用 应用 输入 x 输出 y 垃圾邮件过滤 邮件内容 垃圾邮件 / 非垃圾邮件 语音识别 音频片段 对应文字 机器翻译 源语言文本 目标语言文本 在线广告 广告信息与用户信息 用户是否会点击 自动驾驶 图像、雷达等传感器数据 周围车辆位置 工业视觉检测 产品照片 有缺陷 / 无缺陷 房价预测 房屋面积等信息 房屋价格 每个应用的本质，都是从大量已标注样本中学习输入与输出之间的关系。\n回归：预测一个数值 回归（Regression）是监督学习的一种，目标是预测一个可以连续变化的数值。\n房价预测案例 假设输入是房屋面积，输出是房屋价格，现在需要预测一套 750 平方英尺的房屋值多少钱。\n最简单的方法是拟合一条直线：\n$$f(x)=wx+b$$ w：斜率，表示面积变化对价格的影响 b：截距 直线模型可能预测房屋价值约为 15 万美元。也可以拟合一条更复杂的曲线：\n$$f(x)=w_2x^2+w_1x+b$$曲线模型可能预测房屋价值接近 20 万美元。但不能因为某个模型给出的价格更高就选择它，而应该根据模型在数据上的客观表现进行选择。\n模型太简单，可能无法充分学习规律；模型太复杂，则可能只记住训练数据，也就是发生过拟合（Overfitting），导致它在新数据上表现不好。\n常见回归任务包括预测房价、气温、商品销量、运输时间和用户消费金额。它们的输出都可以在一定范围内连续变化。\n分类：预测一个类别 分类（Classification）也是监督学习的一种，目标是预测一个离散类别。输出只能从有限数量的类别中选择，例如：\n垃圾邮件 / 非垃圾邮件 有缺陷 / 无缺陷 猫 / 狗 患病 / 未患病 乳腺癌检测案例 假设要开发一个辅助医生诊断乳腺癌的系统，根据患者检查数据判断肿瘤属于：\n良性（Benign）：不是癌症，危险程度相对较低 恶性（Malignant）：属于癌症，需要及时治疗 最简单的情况下，只使用肿瘤大小作为输入：\nx：肿瘤大小 y：良性（0）或恶性（1） 每条训练数据可以表示为：\n(1.2, 0) (2.5, 0) (4.8, 1) (6.1, 1) 仅靠肿瘤大小可能不够准确，还可以同时使用患者年龄、肿块厚度、细胞大小均匀性和细胞形状均匀性等特征。此时，输入是一个包含多个特征的向量：\n$$x=(x_1,x_2,\\ldots,x_n)$$监督学习中的 x 不一定是一个数字，也可以是一组特征。\n决策边界 当输入包含肿瘤大小和患者年龄时，可以将样本画在二维坐标中：横轴是肿瘤大小，纵轴是患者年龄，圆圈表示良性，叉号表示恶性。\n分类算法会尝试找到一条决策边界（Decision Boundary），将两类样本区分开来。新患者出现时，模型根据其特征位于边界的哪一侧预测类别。决策边界不一定是直线，也可能是曲线或更复杂的形状。\n回归与分类的区别 对比项 回归 分类 预测目标 连续数值 离散类别 输出范围 可连续变化 有限个类别 典型输出 183000 良性 / 恶性 数字之间是否有连续意义 有 通常没有 判断标准是：模型在回答“具体是多少”，还是“属于哪一类”？\n分类中的类别也可以用数字表示，例如 0 表示良性、1 表示恶性，但这些数字只是类别编号，不代表连续的数量关系。手写数字识别虽然输出 0 到 9，仍然属于分类，因为模型只需要从十个类别中选择，而不是预测任意连续数值。\n二分类与多分类 只有两个可能类别的问题称为二分类（Binary Classification）：\n问题 类别 垃圾邮件检测 垃圾 / 正常 欺诈检测 欺诈 / 正常 疾病检测 患病 / 未患病 广告点击预测 点击 / 不点击 存在三个及以上类别的问题称为多分类（Multiclass Classification）：\n图片分类：猫 / 狗 / 鸟 新闻分类：体育 / 财经 / 科技 / 娱乐 手写数字识别：0～9 疾病诊断：疾病 A / 疾病 B / 疾病 C 类别可以用数字编号，也可以用文字表示。数字在这里只是标签，不能认为“狗 = 猫 + 1”。\n无监督学习 无监督学习（Unsupervised Learning）的数据只有输入 x，没有输出标签 y：\n$$x \\quad \\text{而不是} \\quad (x,y)$$算法需要自行从数据中寻找：\n隐藏的结构 相似的数据群体 数据分布规律 异常的数据点 更简洁的数据表示 “无监督”表示训练数据没有为每个输入提供正确答案，并不代表算法不受控制，也不代表它比监督学习差。\n与监督学习的对比 对比项 监督学习 无监督学习 输入数据 输入 x 和标签 y 只有输入 x 是否有正确答案 有 没有 学习目标 学习 x 到 y 的映射 发现数据中的结构和模式 典型任务 回归、分类 聚类、异常检测、降维 示例 房价预测、疾病诊断 客户分群、新闻聚类 以乳腺癌检测为例：如果数据包含患者年龄、肿瘤大小和良性/恶性标签，就可以用监督学习预测诊断结果；如果只有患者年龄和肿瘤大小而没有标签，算法只能寻找患者之间可能存在的群体或规律。\n聚类：寻找相似群体 聚类（Clustering）的目标是将相似的数据点自动分配到同一个簇（Cluster）中。\n它有三个特点：\n数据没有预先定义的类别标签 算法根据数据之间的相似性进行分组 分组结果通常需要人结合领域知识解释 “相似”取决于选择了哪些特征，以及算法如何计算数据之间的距离。\n谷歌新闻 谷歌新闻每天处理大量报道。如果多篇文章都提到“熊猫、双胞胎、动物园、日本”，聚类算法可能判断它们讲述的是同一事件，并将它们归为一组。\n没有员工提前告诉算法哪些关键词一定属于同一主题。新闻内容每天都在变化，算法必须自动判断当天出现了哪些主题，以及哪些文章正在讨论同一件事。\nDNA与基因数据 DNA 微阵列数据中，每一列表示一个人的基因活动，每一行表示一个特定基因。每个人可以表示为大量基因特征组成的向量。\n聚类算法能够将基因表达模式相似的人分到同一组。算法不知道这些群体应该叫什么，只是在回答：这些基因数据中是否存在自然形成的不同类型？\n客户细分 公司可以根据年龄、地区、购买记录和浏览行为等数据，把相似客户划分到不同细分市场。\n例如，一个在线学习社区可能发现：一组用户希望提升技能，一组希望获得晋升或新工作，另一组希望了解 AI 对自身行业的影响。企业可以据此推荐内容并设计服务，这类应用称为市场细分（Market Segmentation）。\n异常检测：寻找偏离正常模式的数据 异常检测（Anomaly Detection）的目标是发现与大多数数据明显不同的异常样本。\n在金融系统中，一笔交易可能表现出以下异常：\n交易金额特别大 交易地点异常 短时间内连续交易 登录设备突然变化 消费行为与过去完全不同 这些异常可能是欺诈的信号。常见应用还包括网络攻击检测、设备故障检测、工业产品异常检测和医疗异常指标检测。\n需要注意，异常不等于欺诈，只表示该数据与正常模式不同。异常检测也可以采用监督学习方法，但当异常样本很少或没有可靠标签时，经常采用无监督或半监督方法。\n降维：用更少特征表示数据 降维（Dimensionality Reduction）的目标是在尽量保留重要信息的前提下，用更少的特征表示原始数据。\n假设原始数据包含 1000 个特征：\n$$x=(x_1,x_2,\\ldots,x_{1000})$$降维算法可能将其压缩为几十个甚至两三个新特征：\n$$z=(z_1,z_2,\\ldots,z_k),\\quad k \\ll 1000$$降维可以压缩数据、提高部分算法的运行效率、减少重复信息和噪声，也可以将高维数据转换为二维或三维，方便可视化。它不是随意删除数据，而是尽可能保留数据中最重要的信息。\n如何判断学习类型 判断任务时，首先问：训练数据中是否提供了标签？\n邮件带有垃圾/非垃圾标签：监督学习中的分类 房屋带有实际价格：监督学习中的回归 新闻没有主题标签，需要自动分组：无监督学习中的聚类 客户没有预设类型，需要自动细分：无监督学习中的聚类 患者带有患糖尿病/未患糖尿病标签：监督学习中的分类 同一个业务问题也可能因为数据是否有标签，而采用不同的学习方式。\n优势与局限 无监督学习不需要为所有数据人工标注，可以处理大量无标签数据，并发现人们事先没有注意到的结构，因此很适合探索陌生数据。\n它也有明显局限：没有标准答案，结果较难评估；算法找到的结构不一定具有实际意义；聚类结果会受到特征选择和算法的影响；最终通常仍需要领域知识进行解释。\n知识框架 机器学习 ├── 监督学习 │ ├── 回归：预测数值（房价、温度） │ └── 分类：预测类别（垃圾邮件、肿瘤性质） │ ├── 二分类 │ └── 多分类 │ └── 无监督学习 ├── 聚类：寻找相似群体（新闻分组、客户细分） ├── 异常检测：寻找异常数据（欺诈检测） └── 降维：压缩数据并保留重要信息 监督学习使用带标签的 $(x,y)$ 数据，学习输入到输出的映射；无监督学习只有输入 x，目标是自动发现数据中的结构、模式或异常。两者并无高下之分，关键是根据数据和问题选择合适的方法。\n术语表 中文 英文 含义 特征 Feature 描述样本的输入属性 标签 Label 训练数据中的正确答案 泛化 Generalization 对未见数据作出有效预测的能力 回归 Regression 预测连续数值 分类 Classification 预测离散类别 二分类 Binary Classification 只有两个输出类别 多分类 Multiclass Classification 存在三个及以上类别 决策边界 Decision Boundary 区分不同类别的边界 聚类 Clustering 将相似数据自动分组 簇 Cluster 聚类形成的数据群体 异常检测 Anomaly Detection 发现偏离正常模式的数据 降维 Dimensionality Reduction 用更少特征表示高维数据 市场细分 Market Segmentation 根据相似特征划分客户群体 ","date":"2026-07-20T00:00:00+08:00","image":"/MyBlog/p/machine-learning-supervised-unsupervised/cover.svg","permalink":"/MyBlog/p/machine-learning-supervised-unsupervised/","title":"机器学习基础：监督学习与无监督学习"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理、Go 语言工程化：模块管理与项目结构、Go 语言并发：从 Goroutine 到 Worker Pool 和 Go Web 开发：从 net-http 到 Gin，这一篇跳出语法学习，反推一个真实的开源项目——feedsystem_video_go（279 star，Go + Vue 3 短视频 Feed 流系统），从跑通项目到提交 PR，走一遍完整的工程实战。\n项目概览 这个项目是一个短视频 Feed 流系统，技术栈：\n后端：Go + Gin + GORM + RabbitMQ + Redis 前端：Vue 3 + TypeScript 基础设施：Docker Compose 编排 MySQL、Redis、RabbitMQ 核心功能包括用户注册登录、视频发布、点赞评论、关注取关、Feed 流推送。架构上采用 API + Worker 的消息队列模式。\nDocker Compose：启动基础设施 项目的三个\u0026quot;帮手\u0026quot;——MySQL、Redis、RabbitMQ——通过 Docker Compose 一键启动：\ncd ~/dev/projects/feedsystem_video_go docker compose up -d mysql redis rabbitmq docker compose up — 根据 docker-compose.yml 启动服务 -d — 后台运行（detach），不占终端窗口 mysql redis rabbitmq — 只启动这三个，不启动 backend/worker/frontend 镜像 vs 容器 Docker 里有两个核心概念：\n镜像 = 打包好的集装箱（里面有程序 + 配置），是静态文件 容器 = 集装箱正在运行的实例 类比：.app 文件是镜像，双击打开后正在运行就是容器。\n端口映射 MySQL 默认端口 3306，但本机可能已经装了 MySQL 占用了 3306。所以 docker-compose.yml 里写的是：\nports: \u0026#34;3307:3306\u0026#34; 意思是：外部访问 3307 → 转发到容器内部的 3306。就像你家门牌号是 3307，但内部房间编号是 3306。项目配置文件里写的也是 3307，Go 程序连接 MySQL 时用的就是 3307。\n通过 docker compose ps 可以查看所有容器的运行状态：\nNAME IMAGE STATUS PORTS feedsystem_video_go-mysql-1 mysql:8.0 Up 5 minutes (healthy) 0.0.0.0:3307-\u0026gt;3306/tcp feedsystem_video_go-redis-1 redis:7-alpine Up 5 minutes (healthy) 0.0.0.0:6379-\u0026gt;6379/tcp feedsystem_video_go-rabbitmq-1 rabbitmq:3 Up 5 minutes (healthy) 0.0.0.0:5672-\u0026gt;5672/tcp 0.0.0.0 表示\u0026quot;接受任何来源的连接\u0026quot;，即对外部开放。\n启动 Go 后端 基础设施就位后，启动后端：\ncd backend CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd CONFIG_PATH=... — 告诉程序用哪个配置文件（里面写好了 MySQL 3307、Redis 6379、RabbitMQ 5672 的地址） go run ./cmd — 编译并运行 cmd/main.go 入口程序 日志解读 启动后会看到几段日志：\n第一段：依赖下载\ngo: downloading github.com/gin-gonic/gin v1.11.0 go: downloading gorm.io/gorm v1.31.1 Go 在下载第三方库，类似 npm install，下载一次以后就不需要了。\n第二段：连接建立\nLoading config from configs/config.compose-local.yaml Redis connected (cache enabled) RabbitMQ connected 注意没有 \u0026ldquo;MySQL connected\u0026rdquo;，因为 GORM 连接 MySQL 是在后面 AutoMigrate 时才真正连接。\n第三段：Gin 路由注册\n[GIN-debug] GET /healthz [GIN-debug] POST /account/register [GIN-debug] POST /account/login [GIN-debug] POST /video/publish [GIN-debug] POST /like/like [GIN-debug] POST /feed/listLatest 格式是 HTTP方法 路由路径，后面的 (4 handlers) 表示这条路由经过几个中间件（如限流 + JWT 鉴权 + 业务处理）。\n第四段：消息队列\nDLX ready: exchange=dlx.events queue=like.events.dlx Timeline consumer 已启动, queue=video.timeline.update.queue DLX = Dead Letter Exchange（死信队列），专门处理失败的消息。\n第五段：启动完成\nServer is running on port 8080 为什么不把 Go 也放进 Docker？ Docker 适合最终部署，不适合日常开发调试。本地直接 go run 的好处：\n日志直接打印在终端，实时可见 改了代码立刻能跑，不用重新打包镜像 出错了能直接看到报错信息 日志不是中间件 日志是 Go 标准库 log 包写出来的，是程序员手动写的\u0026quot;程序进行到哪一步了\u0026quot;的提示。中间件是处理请求的\u0026quot;关卡\u0026quot;，两者没有关系。\n核心概念：缓存与消息队列 Redis 缓存 缓存 = 把经常用的数据放在\u0026quot;取用更快的地方\u0026quot;。\n类比：冰箱里有水（MySQL，容量大但取用慢），你拿了一瓶放在桌上（Redis 缓存，取用极快），下次喝水直接从桌上拿，不用再去冰箱。\n代码里 cache enabled 表示数据会先从 Redis 取，取不到再去 MySQL。\nRabbitMQ 消息队列 消息队列是 API 和 Worker 之间的\u0026quot;传话筒\u0026quot;。API 处理完核心逻辑后，把\u0026quot;后续要做的事\u0026quot;发到队列，Worker 从队列里取任务执行。\n前后端分离架构 后端和前端是两个完全不同的程序：\n后端：Go 写的，负责业务逻辑、读写数据库，监听 8080 端口 前端：Vue 写的，负责页面展示、用户交互，监听 5173 端口 它们通过 HTTP 请求通信：用户在浏览器点\u0026quot;登录\u0026quot; → 前端发请求到 localhost:8080/account/login → 后端处理 → 返回结果 → 前端展示。\n就像餐厅里厨房（后端）和前厅（前端）是两个地方，前厅接到顾客点单传给厨房，厨房做完菜传给前厅。\n前端用 npm（Node Package Manager）管理依赖，跟 Go 的 go get、Python 的 pip install 是同一个道理：\ncd frontend npm install # 下载依赖 npm run dev # 启动开发服务器 Worker 进程模式 这是整个项目最核心的架构设计。Worker 是一个独立进程，和 API 分开启动：\ncd backend CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd/worker 为什么要分开？ 想象一个外卖平台：\n方案 A：一个人又要接单又要送餐 — 接单时有新订单没人接，送餐时又有新订单没人接，效率低。\n方案 B：两个人分工 — 前台专门接单（API），骑手专门送餐（Worker），前台接完单把订单放进\u0026quot;待送餐队列\u0026quot;（RabbitMQ），骑手从队列里取订单去送。\n对应到项目：\n角色 职责 API 进程 接收用户请求（点赞、评论、发布视频），处理核心业务逻辑，把\u0026quot;后续要做的事\u0026quot;发到消息队列 Worker 进程 监听消息队列，处理异步任务：更新点赞数、计算热度、推送通知 这个项目有四个 Worker：\nLikeWorker — 处理点赞/取消点赞事件 PopularityWorker — 更新视频热度 SocialWorker — 处理关注/取关事件 CommentWorker — 处理评论事件 分开的好处 性能：用户点赞时，API 只需要返回\u0026quot;点赞成功\u0026quot;，不需要等热度计算完成 独立伸缩：请求量大时启动多个 API 进程，任务多时启动多个 Worker 进程 故障隔离：Worker 挂了 API 还能正常接收请求，API 挂了 Worker 还能把队列里的任务处理完 一句话总结：API 是\u0026quot;接单的\u0026quot;，Worker 是\u0026quot;干活的\u0026quot;，通过消息队列解耦。\n开源贡献实战：修复 JWT 密钥缓存 Bug 跑通项目后，在实际使用中发现了一个真实 Bug。\n问题复现 不设置 JWT_SECRET 环境变量，启动后端 注册一个账号 登录成功（页面提示\u0026quot;登录成功\u0026quot;） 但左侧栏仍然显示\u0026quot;未登录\u0026quot; 浏览器控制台报错：invalid or expired token (401) 登录本身成功，但所有需要鉴权的接口全部返回 401。\n根因定位 文件：backend/internal/auth/jwt.go\nfunc jwtSecret() []byte { secret := os.Getenv(\u0026#34;JWT_SECRET\u0026#34;) if secret == \u0026#34;\u0026#34; { b := make([]byte, 32) rand.Read(b) secret = hex.EncodeToString(b) } return []byte(secret) } 问题出在这个函数每次调用都生成一个新的随机密钥：\n登录时：GenerateToken() 调用 jwtSecret() → 拿到密钥 A → 用 A 签名 token 验证时：ParseToken() 调用 jwtSecret() → 拿到密钥 B → 用 B 验证 A ≠ B → 签名不匹配 → 返回 401 修复方案 用包级别的变量缓存密钥，保证每个进程只生成一次：\nvar cachedSecret []byte func jwtSecret() []byte { if cachedSecret != nil { return cachedSecret } secret := os.Getenv(\u0026#34;JWT_SECRET\u0026#34;) if secret == \u0026#34;\u0026#34; { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { log.Printf(\u0026#34;FATAL: cannot generate JWT secret: %v\u0026#34;, err) cachedSecret = []byte(\u0026#34;fallback-unsafe-key-change-me\u0026#34;) return cachedSecret } secret = hex.EncodeToString(b) log.Printf(\u0026#34;WARNING: JWT_SECRET not set, generated random key.\u0026#34;) } cachedSecret = []byte(secret) return cachedSecret } 修复方案兼顾安全性（缓存保证签名一致）和灵活性（仍然允许随机密钥兜底）。\n走通开源贡献流程 步骤 内容 1. 本地复现 按步骤触发 Bug 2. 阅读源码 从前端 → API → JWT 中间件，定位根因 3. 提 Issue 描述问题，附带复现步骤 → #15 4. Fork + 修复分支 fix/jwt-secret-caching 5. 提 PR 关联 Issue → #16 面试话术 在一个 279 star 的开源项目中发现了真实 bug 从前端（Vue/TypeScript）追踪到 API（Gin/Go）再到 JWT 鉴权中间件，定位了根因 提出的修复方案兼顾安全性和灵活性 走通了完整的开源贡献流程 这一篇从\u0026quot;跑通项目\u0026quot;出发，反推了 Docker Compose 编排、Gin 路由注册、Redis 缓存、RabbitMQ 消息队列、Worker 进程模式等工程概念，最后通过一次真实的开源贡献把整条链路串了起来。相比纯语法学习，反推真实项目能更快建立工程直觉。\n","date":"2026-07-19T00:00:00+08:00","image":"/MyBlog/p/go-feed-system-reverse-engineering/cover.svg","permalink":"/MyBlog/p/go-feed-system-reverse-engineering/","title":"Go 项目反推：Feed 流系统实战——从 Docker 到开源贡献"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理、Go 语言工程化：模块管理与项目结构 和 Go 语言并发：从 Goroutine 到 Worker Pool，这一篇进入 Web 开发实战：用 Go 写 HTTP 服务器。\nHandler：处理请求的函数 Go 的 HTTP 服务器一切从 Handler 开始。Handler 就是一个处理请求的函数，签名是固定的：\nfunc handler(w http.ResponseWriter, r *http.Request) w = 响应写入器（往里面写什么，客户端就收到什么） r = 请求信息（客户端发来的 URL、方法、头部等） 把 w 想象成\u0026quot;回复窗口\u0026quot;，r 想象成\u0026quot;来信内容\u0026quot;。\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;net/http\u0026#34; ) func homeHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, \u0026#34;Hello, Go Web!\\n\u0026#34;) } func aboutHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, \u0026#34;About Page\\n\u0026#34;) } 路由注册与启动服务器 告诉 Go：访问 / 调用 homeHandler，访问 /about 调用 aboutHandler。\nfunc main() { // 注册路由 http.HandleFunc(\u0026#34;/\u0026#34;, homeHandler) http.HandleFunc(\u0026#34;/about\u0026#34;, aboutHandler) // 启动服务器，监听 8080 端口 fmt.Println(\u0026#34;Server starting on http://localhost:8080 ...\u0026#34;) err := http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil) if err != nil { fmt.Println(\u0026#34;Server error:\u0026#34;, err) } } ListenAndServe 的含义 Listen = 监听（竖起耳朵听有没有人敲门） And = 并且 Serve = 提供服务（有人来了就接待） 这行代码一旦执行，程序就卡在这不走了，一直等着。下面的 if err 只有服务器出错才会执行。\n端口被占了怎么杀：lsof -ti:8080 | xargs kill -9\nHTTP 方法与 curl r.Method → 请求方法，r.URL.Path → 请求路径。\ncurl 是在终端发 HTTP 请求的工具，相当于用命令代替浏览器：\ncurl http://localhost:8080/ # GET curl -X POST http://localhost:8080/ # POST curl -X PUT http://localhost:8080/ # PUT curl -X DELETE http://localhost:8080/ # DELETE HTTP 方法表示\u0026quot;我想干什么\u0026quot;：\n方法 含义 类比（快递柜） GET 获取数据 查看 123 号格子 POST 提交数据 往里放新包裹 PUT 更新数据 换掉 123 号格子的东西 DELETE 删除数据 拿走 123 号格子的东西 返回 JSON 响应 JSON 是程序之间传递数据的标准格式。核心三步：\nimport \u0026#34;encoding/json\u0026#34; type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` } func userHandler(w http.ResponseWriter, r *http.Request) { user := User{Name: \u0026#34;Tom\u0026#34;, Age: 20} // 第1步：把数据变成 JSON jsonBytes, err := json.Marshal(user) if err != nil { http.Error(w, \u0026#34;序列化失败\u0026#34;, 500) return } // 第2步：告诉客户端\u0026#34;我返回的是 JSON\u0026#34; w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) // 第3步：把 JSON 写出去 w.Write(jsonBytes) } struct 的 json tag type User struct { Name string `json:\u0026#34;name\u0026#34;` // JSON 里字段叫 name（小写） Age int `json:\u0026#34;age\u0026#34;` } 不写 tag 的话字段名就是 Go 里的大写 Name、Age。反引号里整个 json:\u0026quot;xxx\u0026quot; 是一个整体，冒号后面不能有空格。\n响应头是什么 HTTP 响应 = 响应头（信封）+ 响应体（信的内容）：\nw.Header() = 拿信封 w.Header().Set(\u0026quot;Content-Type\u0026quot;, \u0026quot;application/json\u0026quot;) = 在信封上写\u0026quot;里面是 JSON\u0026quot; w.Write() = 往信里写内容 读取客户端 JSON 客户端 POST 发来 JSON，服务器解析到结构体：\nfunc createUserHandler(w http.ResponseWriter, r *http.Request) { // 检查方法 if r.Method != \u0026#34;POST\u0026#34; { http.Error(w, \u0026#34;只支持 POST 方法\u0026#34;, http.StatusMethodNotAllowed) return } // 从请求体解析 JSON 到 user var user User err := json.NewDecoder(r.Body).Decode(\u0026amp;user) if err != nil { http.Error(w, \u0026#34;JSON 解析失败\u0026#34;, 400) return } // 返回给客户端 jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } 测试：\ncurl -X POST http://localhost:8080/user -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; 序列化 vs 反序列化 操作 函数 用途 结构体 → JSON json.Marshal(data) 返回给客户端 JSON → 结构体 json.NewDecoder(r.Body).Decode(\u0026amp;data) 读取客户端数据 关键点：\nDecode 的参数必须是 \u0026amp;user（指针），不是 user Marshal 和 Decode 都返回 err，必须检查 出错要 http.Error + return，不能光打印 REST API 设计风格 REST 的核心思想：用 HTTP 方法 + 路径，表达\u0026quot;对资源做什么操作\u0026quot;。路径放名词（资源），HTTP 方法放动词（操作）。\n本质就是 CRUD：\n操作 HTTP 方法 路径 含义 Create POST /user 新建 Read GET /user 或 /user/0 查列表 / 查单个 Update PUT /user/0 改单个 Delete DELETE /user/0 删单个 记忆：GET=看，POST=增，PUT=改，DELETE=删\n设计规则：\nURL 用名词（/user），不用动词（/getUser） 用 HTTP 方法区分操作，而不是路径里写动词 同一个 URL，不同方法 = 不同操作 完整 CRUD 实战 四个 Handler 的套路 不管你写哪个 handler，步骤都是：\n检查 HTTP 方法对不对 从 URL 拿 id（列表操作跳过） 检查越界（同上） 业务逻辑（创建/返回/替换/删除） 序列化返回（JSON → 设 Header → 写响应） 区别只在第 4 步：\nCreate：从请求体解析 → append 到 users Read：从 users 里取 → 序列化返回 Update：从请求体解析 → 替换 users[id] Delete：从 users 删掉 → 返回文字消息 结构体与全局变量 type User struct { Name string `json:\u0026#34;name\u0026#34;` Age int `json:\u0026#34;age\u0026#34;` } // 模拟数据库，服务器重启就清空 var users = []User{} 从 URL 提取 id 客户端访问 GET /user/0，服务器用 TrimPrefix 切掉前缀，再转数字：\nfunc getUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;GET\u0026#34; { http.Error(w, \u0026#34;只支持 GET 方法\u0026#34;, 405) return } // /user/0 → \u0026#34;0\u0026#34; idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) // \u0026#34;0\u0026#34; → 0 id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } // 越界检查 if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } user := users[id] jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } 越界用 \u0026gt;= 不是 \u0026gt;：长度 2 的列表，有效下标是 0、1，id=2 要拦截。\nUpdate Handler（PUT） 套路：检查方法 → 拿 id → 越界检查 → 解析请求体新数据 → 替换 users[id] → 序列化返回。\nfunc updateUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;PUT\u0026#34; { http.Error(w, \u0026#34;只支持 PUT 方法\u0026#34;, 405) return } idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } var user User err = json.NewDecoder(r.Body).Decode(\u0026amp;user) if err != nil { http.Error(w, \u0026#34;解析失败\u0026#34;, 400) return } users[id] = user jsonData, _ := json.Marshal(user) w.Header().Set(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;) w.Write(jsonData) } Delete Handler（DELETE） 套路：检查方法 → 拿 id → 越界检查 → 从 slice 删元素 → 返回消息。\nfunc deleteUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != \u0026#34;DELETE\u0026#34; { http.Error(w, \u0026#34;方法错误\u0026#34;, 405) return } idStr := strings.TrimPrefix(r.URL.Path, \u0026#34;/user/\u0026#34;) id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, \u0026#34;无效的 id\u0026#34;, 400) return } if id \u0026lt; 0 || id \u0026gt;= len(users) { http.Error(w, \u0026#34;越界\u0026#34;, 404) return } // 删 slice 元素 users = append(users[:id], users[id+1:]...) fmt.Fprintf(w, \u0026#34;用户 %d 已删除\\n\u0026#34;, id) } Slice 删除元素的原理 users = append(users[:id], users[id+1:]...) 把 id 前面的人和 id 后面的人拼到一起，跳过 id 本身。假设 users = [张三, 李四, 王五]，删 id=1（李四）：\nusers[:1] → [张三] users[2:] → [王五] append 拼起来 → [张三, 王五] 注意：删除后 users 长度变了，后面的 id 会前移。\n路由分发（switch 重构） 当一个 URL 要根据 HTTP 方法分发到不同 handler 时：\nfunc main() { http.HandleFunc(\u0026#34;/user/\u0026#34;, func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == \u0026#34;/user\u0026#34; || path == \u0026#34;/user/\u0026#34; { // 列表和创建 switch r.Method { case \u0026#34;GET\u0026#34;: listUsersHandler(w, r) case \u0026#34;POST\u0026#34;: createUserHandler(w, r) default: http.Error(w, \u0026#34;方法不支持\u0026#34;, 405) } } else if strings.HasPrefix(path, \u0026#34;/user/\u0026#34;) { // 单个用户操作 switch r.Method { case \u0026#34;GET\u0026#34;: getUserHandler(w, r) case \u0026#34;PUT\u0026#34;: updateUserHandler(w, r) case \u0026#34;DELETE\u0026#34;: deleteUserHandler(w, r) default: http.Error(w, \u0026#34;方法不支持\u0026#34;, 405) } } }) fmt.Println(\u0026#34;Server starting on http://localhost:8080 ...\u0026#34;) err := http.ListenAndServe(\u0026#34;:8080\u0026#34;, nil) if err != nil { fmt.Println(\u0026#34;Server error:\u0026#34;, err) } } 测试 # 查列表（空的） $ curl http://localhost:8080/user/ [] # 创建两个用户 $ curl -X POST http://localhost:8080/user/ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25}\u0026#39; {\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25} $ curl -X POST http://localhost:8080/user/ -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30}\u0026#39; {\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30} # 再查列表 $ curl http://localhost:8080/user/ [{\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25},{\u0026#34;name\u0026#34;:\u0026#34;Tom\u0026#34;,\u0026#34;age\u0026#34;:30}] # 查单个 $ curl http://localhost:8080/user/0 {\u0026#34;name\u0026#34;:\u0026#34;Jerry\u0026#34;,\u0026#34;age\u0026#34;:25} ServeMux 的斜杠重定向坑 注册 /user/ 这种子树路由后，Go 默认路由器会把 /user 自动重定向到 /user/（301 Moved Permanently）。一些客户端跟随 301 重定向时会把 POST 改成 GET，导致请求体丢失；另一些客户端不会自动跟随重定向。\n解决：访问列表/创建接口时直接使用带斜杠的 /user/，或者同时显式注册 /user 与 /user/ 并自行处理。\nMiddleware 中间件 一句话理解 在请求到达 handler 之前（或之后）执行的通用逻辑。\n比喻：快递站的安检机——每个包裹都要过一遍，但安检机不是最终处理包裹的地方，安检完再交给真正处理的人。\n实际用途：\n日志记录：每个请求都打印一下 认证检查：检查有没有登录 请求计时：每个请求花了多少时间 核心代码 func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 前置：请求进来时 log.Println(\u0026#34;收到请求:\u0026#34;, r.URL.Path) // 调用真正的 handler next(w, r) // 后置：响应返回后 log.Println(\u0026#34;处理完成\u0026#34;) } } 使用方式 // 原来 http.HandleFunc(\u0026#34;/user\u0026#34;, createUserHandler) // 包一层中间件 http.HandleFunc(\u0026#34;/user\u0026#34;, loggingMiddleware(createUserHandler)) 理解方式：\nloggingMiddleware(createUserHandler) ↑ ↑ 前台（先接客） 厨师（真正干活） 流程：请求进来 → 前台登记 → 转给厨师 → 做完 → 前台记录 多个中间件叠加 http.HandleFunc(\u0026#34;/user\u0026#34;, authMiddleware(loggingMiddleware(createUserHandler))) // 请求进来 → auth → logging → createUserHandler 本质：函数当参数传 + 函数当返回值。不用完全理解原理，记住套路就行，写多了自然就懂。\nGin 框架 Gin 是什么 Go 最流行的 Web 框架，帮你省掉 net/http 的重复代码。\ngo get github.com/gin-gonic/gin 创建服务器 import \u0026#34;github.com/gin-gonic/gin\u0026#34; r := gin.Default() // 创建路由器，自带 Logger 中间件 r.Run(\u0026#34;:8080\u0026#34;) // 启动服务器 Gin vs net/http 对比 net/http Gin w http.ResponseWriter, r *http.Request c *gin.Context if r.Method != \u0026quot;GET\u0026quot; ... 不用写（r.GET 自动限定） json.NewDecoder(r.Body).Decode() c.ShouldBindJSON(\u0026amp;user) json.Marshal + w.Header + w.Write c.JSON(200, data) strings.TrimPrefix + Atoi c.Param(\u0026quot;id\u0026quot;) http.HandleFunc + if/switch r.GET/POST/PUT/DELETE 分开注册 http.ListenAndServe(\u0026quot;:8080\u0026quot;, nil) r.Run(\u0026quot;:8080\u0026quot;) 路由注册 r.GET(\u0026#34;/user\u0026#34;, listUsers) // GET /user → listUsers r.POST(\u0026#34;/user\u0026#34;, createUser) // POST /user → createUser r.GET(\u0026#34;/user/:id\u0026#34;, getUser) // GET /user/0 → getUser r.PUT(\u0026#34;/user/:id\u0026#34;, updateUser) // PUT /user/0 → updateUser r.DELETE(\u0026#34;/user/:id\u0026#34;, deleteUser) // DELETE /user/0 → deleteUser :id 是路由参数，/user/0 里的 0 会被捕获。\nHandler 签名 func handler(c *gin.Context) { // c 把 w 和 r 合到一起了，用 c 就能完成所有事情 } 获取路由参数与查询参数 // 路由参数 id := c.Param(\u0026#34;id\u0026#34;) // /user/0 → \u0026#34;0\u0026#34; // 查询参数 name := c.Query(\u0026#34;name\u0026#34;) // /user?name=张三 → \u0026#34;张三\u0026#34; page := c.DefaultQuery(\u0026#34;page\u0026#34;, \u0026#34;1\u0026#34;) // 没传就用默认值 \u0026#34;1\u0026#34; 解析请求体 JSON var user User if err := c.ShouldBindJSON(\u0026amp;user); err != nil { c.JSON(400, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } // 解析成功，user 里有数据了 ShouldBindJSON 一行代替 net/http 的三行（NewDecoder + Decode + 检查 err）。\n返回 JSON 响应 c.JSON(200, user) // 返回单个对象 c.JSON(200, users) // 返回列表 c.JSON(400, gin.H{\u0026#34;error\u0026#34;: \u0026#34;解析失败\u0026#34;}) // 返回错误信息 gin.H 是 map[string]interface{} 的简写。c.JSON 自动帮你做：序列化 + 设 Content-Type + 写响应。\n参数校验 在 struct tag 里加 binding 规则，Gin 自动校验：\ntype User struct { Name string `json:\u0026#34;name\u0026#34; binding:\u0026#34;required,min=2,max=50\u0026#34;` Age int `json:\u0026#34;age\u0026#34; binding:\u0026#34;required,gt=0\u0026#34;` } 常用规则：\nrequired → 必填 min=N / max=N → 最小/最大长度（字符串）或值（数字） gt=N / gte=N → 大于 / 大于等于 email → 必须是邮箱格式 校验在 c.ShouldBindJSON 时自动执行，不需要额外代码。\nGin 中间件 func Logger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() log.Printf(\u0026#34;\u0026gt;\u0026gt;\u0026gt; %s %s\u0026#34;, c.Request.Method, c.Request.URL.Path) c.Next() // 放行 cost := time.Since(start) log.Printf(\u0026#34;\u0026lt;\u0026lt;\u0026lt; 耗时: %v\u0026#34;, cost) } } 核心两个函数：\nc.Next() → 放行，让请求继续走到 handler c.Abort() → 拦截，不让请求继续走 认证中间件 func AuthRequired() gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader(\u0026#34;Authorization\u0026#34;) if token == \u0026#34;\u0026#34; { c.JSON(401, gin.H{\u0026#34;error\u0026#34;: \u0026#34;未登录\u0026#34;}) c.Abort() // 拦截 return } c.Next() // 放行 } } 路由分组 用法1：单个路由加中间件\nr.GET(\u0026#34;/user\u0026#34;, Logger(), listUsers) r.GET(\u0026#34;/user/:id\u0026#34;, Logger(), AuthRequired(), getUser) 用法2：路由分组（推荐）\nauthorized := r.Group(\u0026#34;/user\u0026#34;, AuthRequired()) { authorized.GET(\u0026#34;/:id\u0026#34;, Logger(), getUser) authorized.PUT(\u0026#34;/:id\u0026#34;, Logger(), updateUser) authorized.DELETE(\u0026#34;/:id\u0026#34;, Logger(), deleteUser) } 分组的好处：AuthRequired 写一次，这组路由全部生效。\n完整示例（带中间件） func main() { r := gin.Default() // 不需要登录的接口 r.GET(\u0026#34;/user\u0026#34;, listUsers) r.POST(\u0026#34;/user\u0026#34;, createUser) // 需要登录的接口 auth := r.Group(\u0026#34;/user\u0026#34;, AuthRequired()) { auth.GET(\u0026#34;/:id\u0026#34;, getUser) auth.PUT(\u0026#34;/:id\u0026#34;, updateUser) auth.DELETE(\u0026#34;/:id\u0026#34;, deleteUser) } r.Run(\u0026#34;:8080\u0026#34;) } 常用函数速查 函数 作用 gin.Default() 创建路由器（自带 Logger） r.GET/POST/PUT/DELETE(path, fn) 注册路由 r.Group(path, middleware...) 路由分组 c.Param(\u0026quot;id\u0026quot;) 获取路由参数 c.Query(\u0026quot;key\u0026quot;) 获取查询参数 c.DefaultQuery(\u0026quot;key\u0026quot;, \u0026quot;default\u0026quot;) 获取查询参数（带默认值） c.GetHeader(\u0026quot;key\u0026quot;) 获取请求头 c.ShouldBindJSON(\u0026amp;data) 解析请求体 JSON（含校验） c.JSON(status, data) 返回 JSON 响应 c.Next() 放行 c.Abort() 拦截 gin.H{...} map[string]interface{} 简写 踩坑汇总 中文输入法逗号：\u0026quot;错误\u0026quot;，405 → 要用英文逗号 \u0026quot;错误\u0026quot;, 405 := vs =：全局变量用 = 赋值，用 := 会创建局部变量 fmt.Fprinf → fmt.Fprintf（p 和 r 反了） json.Decode 拼写：Docode → Decode application 拼写：spplication → application c.Abort 漏括号 → c.Abort() 才是调用 Gin 里用 net/http 的写法：json.NewDecoder(r.Body) 是 net/http 的，Gin 里用 c.ShouldBindJSON net/http 中 POST /user 不带斜杠：注册的是 /user/ 时会被重定向，测试时直接用 /user/ 参考源 raw/GoWeb 后端开发.md ","date":"2026-07-18T00:00:00+08:00","image":"/MyBlog/p/go-web-net-http-to-gin/cover.svg","permalink":"/MyBlog/p/go-web-net-http-to-gin/","title":"Go Web 开发：从 net-http 到 Gin"},{"content":"承接 Go 语言基础：从变量到指针、Go 语言进阶：方法、接口与错误处理 和 Go 语言工程化：模块管理与项目结构，这一篇覆盖 Go 最核心的特性：并发编程。\n并发 vs 并行 先区分两个概念：\n并行（Parallel）：两个人同时吃两碗面 并发（Concurrency）：一个人交替吃两碗面（这口吃这碗，下口吃那碗） Go 的并发模型就是让你用很少的资源同时做很多事。\nGoroutine 一句话：轻量级线程，用 go 关键字启动。\n// 普通调用 fmt.Println(\u0026#34;hello\u0026#34;) // 用 go 启动一个 goroutine go fmt.Println(\u0026#34;hello\u0026#34;) // 加个 go 就行 加了 go，这个函数就会在后台跑，不阻塞当前代码。\n先看个例子 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func sayHello() { fmt.Println(\u0026#34;hello\u0026#34;) } func main() { go sayHello() // 后台跑 fmt.Println(\u0026#34;main\u0026#34;) // 主线程继续 } 你猜输出什么？\n答案：只输出 main。\n为什么？因为 main 函数结束了，程序就退出了，goroutine 还没来得及跑。\n这就像你叫了个外卖（go sayHello），但你立刻出门了（main 结束），外卖员来了发现没人。\n怎么等 goroutine 跑完？ 最简单的方式：加个 sleep（但这是错误示范）\ngo sayHello() time.Sleep(time.Second) // 等1秒，goroutine 有时间跑了 fmt.Println(\u0026#34;main\u0026#34;) 能工作，但不准、不优雅——你不知道 goroutine 到底要跑多久。\n正确方式：用 channel（后面会详细讲）\ndone := make(chan bool) go func() { fmt.Println(\u0026#34;hello\u0026#34;) done \u0026lt;- true // 说一声：我做完了 }() \u0026lt;-done // 等着，直到收到信号 fmt.Println(\u0026#34;main\u0026#34;) 现在输出：\nhello main 用比喻理解 普通函数调用：你叫同事做事，站在旁边等他做完 go 函数调用：你叫同事做事，自己先去忙别的 channel：同事做完了发消息通知你 Channel 为什么需要 Channel？ goroutine 是独立运行的，它们之间怎么传数据？\n// 两个 goroutine，一个生产数据，一个消费数据 // 怎么把数据从 A 传到 B？ 答案：用 channel，就像一根水管，一头进，一头出。\n基本用法 // 创建 channel ch := make(chan int) // 传 int 类型的 channel // 发送数据（往管子里塞） ch \u0026lt;- 42 // 接收数据（从管子里取） x := \u0026lt;-ch Channel 的特性：阻塞 这是最关键的：\n发送方：ch \u0026lt;- 42 → 没人接收就等着（阻塞） 接收方：x := \u0026lt;-ch → 没人发送就等着（阻塞） 就像一个没有缓冲的传送带：\n你放东西上去，必须有人在另一头接着，否则你就一直举着 你去拿东西，必须有人放了东西，否则你就一直等着 用阻塞特性来同步 这就是为什么 channel 能替代 sleep 来等 goroutine：\ndone := make(chan bool) go func() { fmt.Println(\u0026#34;干活中...\u0026#34;) done \u0026lt;- true // 干完了，发信号 }() \u0026lt;-done // 等着，直到收到信号 fmt.Println(\u0026#34;结束\u0026#34;) goroutine 没发信号 → main 在 \u0026lt;-done 那行等着 goroutine 发了信号 → main 继续往下跑 缓冲 Channel 普通 channel 没有缓冲，一发一收必须配对。可以加缓冲：\nch := make(chan int, 3) // 缓冲区大小为3 ch \u0026lt;- 1 // 塞进去，不等（缓冲区没满） ch \u0026lt;- 2 // 塞进去，不等 ch \u0026lt;- 3 // 塞进去，不等 ch \u0026lt;- 4 // 缓冲区满了，等着（阻塞） 比喻：\n没缓冲：打电话，必须对方接了你才能说话 有缓冲：发微信，对方没看之前你也能发几条 关闭 Channel 发送方说\u0026quot;我不再发了\u0026quot;：\nclose(ch) 接收方可以检测 channel 是否关闭：\nx, ok := \u0026lt;-ch if !ok { fmt.Println(\u0026#34;channel 已关闭\u0026#34;) } 概念总结 操作 含义 make(chan T) 创建 channel ch \u0026lt;- data 发送 x := \u0026lt;-ch 接收 close(ch) 关闭 make(chan T, n) 带缓冲的 channel Select Select 是什么？ 一句话：同时监听多个 channel，哪个先有数据就处理哪个。\n为什么需要 Select？ 假设你有两个 channel：\nch1 := make(chan string) ch2 := make(chan string) 用普通方式只能等一个：\nmsg := \u0026lt;-ch1 // 只能等 ch1，ch2 来了也不知道 用 select 可以同时等：\nselect { case msg := \u0026lt;-ch1: fmt.Println(msg) case msg := \u0026lt;-ch2: fmt.Println(msg) } 哪个先有数据就执行哪个。\n完整例子 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) func main() { ch1 := make(chan string) ch2 := make(chan string) // 1秒后往 ch1 塞数据 go func() { time.Sleep(1 * time.Second) ch1 \u0026lt;- \u0026#34;来自 ch1\u0026#34; }() // 2秒后往 ch2 塞数据 go func() { time.Sleep(2 * time.Second) ch2 \u0026lt;- \u0026#34;来自 ch2\u0026#34; }() // 同时等 ch1 和 ch2 select { case msg := \u0026lt;-ch1: fmt.Println(msg) case msg := \u0026lt;-ch2: fmt.Println(msg) } } 输出：来自 ch1（因为 ch1 先到）\nsync.WaitGroup 为什么需要 WaitGroup？ 你启动了多个 goroutine，想等它们全部做完再继续：\ngo task1() go task2() go task3() // 怎么知道三个都做完了？ 之前用 channel 可以，但要创建多个 channel 很麻烦。WaitGroup 就是专门解决这个的。\n怎么用？三步 var wg sync.WaitGroup wg.Add(1) // 第1步：告诉 wg 你要等几个任务 go func() { // 做事情... wg.Done() // 第3步：做完了，报告一下 }() wg.Wait() // 第2步：等着，全部做完才继续 sync.Mutex 互斥锁 解决的问题：多个 goroutine 同时读写同一个变量。\nvar mu sync.Mutex mu.Lock() counter++ mu.Unlock() sync.Once var once sync.Once once.Do(func() { // 这段代码只会执行一次 }) 不管你调多少次 once.Do()，里面的函数只执行第一次。\n什么时候用？初始化操作，比如数据库连接、配置加载，只做一次。\nContext Context 是什么？ 一句话：控制 goroutine 的生命周期，告诉它\u0026quot;别干了，取消\u0026quot;。\n为什么需要 context？ 假设你发了一个 HTTP 请求，用户等不及关了页面：\n用户 → 请求 → 后端启动 goroutine 处理 → 数据库查询中... 用户关了页面 goroutine 还在跑，浪费资源。 用 context 可以通知 goroutine：\u0026ldquo;别查了，用户走了\u0026rdquo;。\n先忘掉代码，想一个场景 你叫了一个外卖：\n你：下单（启动 goroutine） 外卖员：开始送餐（goroutine 在跑） 情况1：你等不及了，打电话说\u0026quot;别送了\u0026quot;（手动取消） 情况2：你下单时就说\u0026quot;30分钟不到就取消\u0026quot;（超时取消） context 就是那个电话。\n代码里的角色 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx → 外卖员手里的对讲机（能听到\u0026quot;取消\u0026quot;的信号） cancel → 你手里的电话（主动打过去说\u0026quot;取消\u0026quot;） 2*time.Second → 30分钟超时（2秒后自动取消） goroutine 那边怎么听信号？ select { case \u0026lt;-ctx.Done(): // 对讲机响了，说\u0026#34;取消\u0026#34; fmt.Println(\u0026#34;收到取消，退出\u0026#34;) return default: // 没响，继续干活 fmt.Println(\u0026#34;干活中...\u0026#34;) } ctx.Done() 就是对讲机，context 取消时它就响（channel 关闭）。\n整个流程 main：创建 context（设定2秒超时） main：启动 worker，把对讲机给它 worker：干活\u0026hellip;干活\u0026hellip; 2秒后：超时，context 自动取消 worker：听到对讲机响了，退出 两种取消方式 1. 手动取消：WithCancel ctx, cancel := context.WithCancel(context.Background()) 你自己决定什么时候取消：\ngo worker(ctx) // 过了一会儿，你决定取消 cancel() // 打电话说\u0026#34;别干了\u0026#34; 就像：你叫了外卖，等了5分钟不耐烦了，主动打电话取消。\n2. 超时取消：WithTimeout ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 设个时间，到了自动取消：\ngo worker(ctx) // 不用管了，2秒后自动取消 // cancel() 也可以提前调，但不是必须的 Race Detector 什么是数据竞争？ 之前 Mutex 的例子，不加锁会出错：\ncounter++ // 多个 goroutine 同时改，结果不对 这就是数据竞争，很难复现，偶尔出错，排查很痛苦。\nRace Detector Go 自带一个工具，帮你检测数据竞争：\ngo run -race demo.go go build -race -o app go test -race ./... 加一个 -race 就行。\n看个例子 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; ) func main() { counter := 0 var wg sync.WaitGroup for i := 0; i \u0026lt; 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ }() } wg.Wait() fmt.Println(\u0026#34;counter:\u0026#34;, counter) } go run -race test.go 输出：\nWARNING: DATA RACE ... Found 2 data race(s) -race 告诉你什么 哪个 goroutine 在读（goroutine 12） 哪个 goroutine 在写（goroutine 8） 在第几行（第16行：counter++） 一句话总结 go run -race → 跑代码 + 检测数据竞争 go test -race → 跑测试 + 检测数据竞争（写项目时常用） Worker Pool 并发模式 一句话：固定几个 goroutine，从队列里取任务执行。\n为什么需要？ 假设你有1000个任务：\n// 方式1：每个任务一个 goroutine for i := 0; i \u0026lt; 1000; i++ { go doTask(i) } 启动1000个 goroutine，资源占用大，不好控制。\n// 方式2：Worker Pool，只启动3个 worker for i := 0; i \u0026lt; 3; i++ { go worker(tasks) } // 3个 worker 从 tasks 里取任务，取完一个再取下一个 就像餐厅只有3个厨师，100个菜排队等他们做，做完一个做下一个。\n模型 任务队列（channel）：[任务1, 任务2, 任务3, ...任务N] Worker 1：从队列取任务 → 执行 → 再取下一个 Worker 2：从队列取任务 → 执行 → 再取下一个 Worker 3：从队列取任务 → 执行 → 再取下一个 完整代码 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) // worker 函数：每个 worker 从 tasks 这个 channel 里不断取任务执行 // id：worker 的编号，用来区分是哪个 worker 在干活 // tasks：任务队列，只读 channel（\u0026lt;-chan 表示只能从里面取，不能塞） func worker(id int, tasks \u0026lt;-chan int) { // range tasks：从 channel 里取任务，取到就执行 // channel 关闭后，range 自动结束，for 循环退出 for task := range tasks { fmt.Printf(\u0026#34;worker %d 开始处理任务 %d\\n\u0026#34;, id, task) time.Sleep(time.Second) // 模拟干活耗时1秒 fmt.Printf(\u0026#34;worker %d 完成任务 %d\\n\u0026#34;, id, task) } } func main() { // 创建一个带缓冲的 channel，缓冲区大小10 tasks := make(chan int, 10) // 启动3个 worker，用 goroutine 并发跑 for i := 1; i \u0026lt;= 3; i++ { go worker(i, tasks) } // 往 tasks 里塞5个任务（编号1到5） for j := 1; j \u0026lt;= 5; j++ { tasks \u0026lt;- j } // 关闭 channel，告诉 worker：\u0026#34;没有新任务了\u0026#34; close(tasks) // 等6秒，让 worker 有时间把任务做完 time.Sleep(6 * time.Second) fmt.Println(\u0026#34;全部完成\u0026#34;) } 执行流程 main：启动 worker 1、worker 2、worker 3 main：塞任务 1、2、3、4、5 worker 1：取到任务1，开始干 worker 2：取到任务2，开始干 worker 3：取到任务3，开始干 worker 1：任务1做完，取任务4 worker 2：任务2做完，取任务5 worker 3：任务3做完，队列空了，等着\u0026hellip; main：close(tasks) → worker 们收到关闭信号，全部退出 main：打印\u0026quot;全部完成\u0026quot; 到底怎么保证\u0026quot;干完了取下一个\u0026quot;还很有序？ 关键：channel 的阻塞特性。\nfor task := range tasks { // 干活... } range tasks 会自动做这件事：\n有任务 → 取出来，执行 没任务 → 阻塞，等着 channel 关闭 → 退出 for 循环 展开来看真实流程：\n任务队列（channel）：[1, 2, 3, 4, 5] worker 1：range tasks → 取到 1 → 开始干 worker 2：range tasks → 取到 2 → 开始干 worker 3：range tasks → 取到 3 → 开始干 // 三个 worker 都在干活，队列里还有 4 和 5 worker 1：干完 → range tasks → 取到 4 → 开始干 worker 2：干完 → range tasks → 取到 5 → 开始干 worker 3：干完 → range tasks → 队列空了 → 阻塞，等着 // 没有新任务了 main：close(tasks) worker 3：检测到关闭 → 退出 worker 1：干完任务4 → 检测到关闭 → 退出 worker 2：干完任务5 → 检测到关闭 → 退出 为什么不需要锁？ 因为 channel 天然安全：\n同一个任务不会被两个 worker 取到 channel 保证：取走一个就少一个 一句话总结 range channel → 没任务就阻塞，有任务就取一个，取完再取下一个。channel 关闭 → range 自动结束。\n参考源 raw/Go.md ","date":"2026-07-14T00:00:00+08:00","image":"/MyBlog/p/go-concurrency-goroutines-worker-pool/cover.svg","permalink":"/MyBlog/p/go-concurrency-goroutines-worker-pool/","title":"Go 语言并发：从 Goroutine 到 Worker Pool"},{"content":"承接 Go 语言基础：从变量到指针 和 Go 语言进阶：方法、接口与错误处理，这一篇覆盖 Go 项目的工程化核心：模块管理、包的组织、目录结构。\nGo Modules 之前写的代码都在单个 .go 文件里，使用 package main，直接 go run xxx.go 就能跑。但真实项目通常会有几十个 .go 文件、多个包，还会用到第三方库。这些东西怎么管理？答案就是 Go Modules。\ngo.mod 是什么？ go.mod 就像项目的“身份证”，它主要记录三类信息：\n模块路径是什么：module example.com/demo 项目使用哪个 Go 版本：go 1.26.3 项目依赖哪些第三方模块：由 require 指令记录 为什么要用 Go Modules？ 假设你写了一个项目，用了第三方库。朋友拿到代码后，怎么知道需要哪些依赖和版本？\n没有 go.mod：\n朋友：你用了哪些库？\n你：好像有 xxx、yyy，版本忘了……\n有 go.mod：\n朋友：看 go.mod 就知道了，执行 go mod download 或 go mod tidy 就能准备好依赖。\n常用命令 go mod init 模块路径 在项目开始时创建 go.mod：\ngo mod init example.com/demo 生成的文件类似这样：\nmodule example.com/demo go 1.26.3 练习项目可以使用 demo 这类简单名称；准备发布的模块通常使用代码仓库地址，例如 github.com/用户名/项目名，这样别人才能通过稳定的路径导入它。\ngo mod tidy go mod tidy 会根据源码中的 import 整理依赖：\n添加代码需要、但 go.mod 还没有记录的模块 删除代码已经不再需要的模块 补充或清理 go.sum 中的校验信息 简单说，它会让模块文件与当前代码保持一致。改完依赖后跑一次，通常是个好习惯。\ngo get 模块路径 给当前模块添加或升级依赖：\ngo get github.com/gin-gonic/gin 它会解析并下载依赖，同时更新 go.mod 和 go.sum。如果你要安装一个命令行工具，而不是给当前项目添加依赖，应使用带版本号的 go install：\ngo install example.com/tool@latest go.sum 是什么？ 执行 go mod tidy、go get 或构建项目后，通常会出现 go.sum。它记录依赖模块内容的加密校验值：\n项目使用某个模块的特定版本 go.sum 保存这个版本及其 go.mod 文件的校验值 再次下载时，Go 会验证拿到的内容是否一致 go.sum 不是用来锁死所有依赖版本的锁文件；真正决定依赖版本的是 go.mod 和 Go 的版本选择规则。go.mod 与 go.sum 都应该提交到 Git，通常不需要手动编辑 go.sum。\npackage 拆分 为什么要拆 package？ 如果所有代码都堆在 main.go 里，用户管理、订单管理和数据库连接会逐渐混在一起。按职责拆包之后，结构会清晰很多：\ndemo/ go.mod main.go # 程序入口 user/ user.go # 用户相关功能 order/ order.go # 订单相关功能 db/ db.go # 数据库相关功能 怎么拆？ 先创建 user/user.go，声明它属于 user 包：\npackage user import \u0026#34;fmt\u0026#34; type User struct { Name string Age int } func Print(u User) { fmt.Printf(\u0026#34;%s, %d\\n\u0026#34;, u.Name, u.Age) } 再在项目根目录的 main.go 中导入它：\npackage main import \u0026#34;example.com/demo/user\u0026#34; func main() { u := user.User{Name: \u0026#34;Jasper\u0026#34;, Age: 22} user.Print(u) } 这里的导入路径由两部分组成：\nexample.com/demo/user └── 模块路径 ──┘ └包目录 也就是：导入路径 = go.mod 中的模块路径 + 包所在的相对目录。\npackage 与可见性 同一目录下的普通 .go 文件必须属于同一个包。包名通常与目录名最后一段一致，这是一条强烈推荐的惯例，但不是“包名必须等于目录名”的语法规则。\n标识符是否能被其他包使用，则由首字母大小写决定：\n写法 可见范围 User、Print 首字母大写，可以被其他包访问 user、print 首字母小写，只能在当前包内访问 internal 访问控制 internal 是什么？ 一句话：internal 是 Go 编译器真正认识的特殊目录，用来限制包的导入范围。\n假设项目结构如下：\nmyapp/ go.mod # module example.com/myapp cmd/api/main.go internal/db/db.go example.com/myapp/internal/db 只能被 example.com/myapp 目录树中的代码导入：\n// myapp/cmd/api/main.go：可以导入 import \u0026#34;example.com/myapp/internal/db\u0026#34; 另一个项目则不能导入它：\n// otherapp/main.go：编译报错 import \u0026#34;example.com/myapp/internal/db\u0026#34; 更准确地说，规则不是“同一个模块才能用”，而是：只有位于 internal 父目录树中的代码才能导入它。大多数项目把 internal 放在模块根目录，所以看起来就像“只有本模块能用”。\n目录 编译器是否特殊处理 用途 internal/ 是，限制外部导入 不希望暴露给外部项目的实现 pkg/ 否，只是社区惯例 明确准备对外复用的包，可选 常见项目结构 Go 官方没有规定一套所有项目都必须照搬的“标准目录结构”。下面是一种常见布局，适合已经出现多个入口和多个业务模块的项目：\nmyproject/ cmd/ # 程序入口 api/ main.go # 启动 HTTP 服务 worker/ main.go # 启动后台任务 internal/ # 不对模块外部开放的代码 config/ config.go # 配置读取 db/ db.go # 数据库连接 user/ user.go # 用户相关逻辑 handler/ handler.go # HTTP 处理函数 pkg/ # 确实需要对外复用时再添加 utils/ utils.go go.mod go.sum 三个常见目录的职责：\ncmd/：每个子目录对应一个可执行程序，包名为 main；通常只负责依赖组装、配置和启动 internal/：放不希望被外部项目导入的实现代码，不代表所有业务逻辑都必须塞在这里 pkg/：表达“这些包准备公开复用”的意图，没有编译器层面的特殊含义；小项目完全可以没有 目录不是越多越工程化。只有一个可执行程序的小项目，把 main.go 放在根目录也很正常；随着入口和职责增多，再引入 cmd/、internal/ 即可。\n为什么要多一层 cmd/？ 一个项目可能提供多个启动方式：\ncmd/api/main.go # 启动 HTTP 服务 cmd/worker/main.go # 启动后台任务 它们是两个入口，但可以共享 internal/ 里的业务代码。cmd/ 下的程序应尽量轻量：\n// cmd/api/main.go package main import \u0026#34;example.com/myproject/internal/server\u0026#34; func main() { server.Start() } cmd/api 中不一定只能有一个 main.go，也可以有多个属于 package main 的文件；重要的是入口层保持轻薄，不把大量业务逻辑堆在这里。\n引入第三方包 标准库是 Go 自带的，例如 fmt、net/http。标准库之外，由其他项目提供的包就是第三方包，例如：\ngithub.com/gin-gonic/gin：Web 框架 github.com/jackc/pgx/v5：PostgreSQL 驱动与工具包 github.com/spf13/cobra：命令行应用框架 以 UUID 包为例，先添加依赖：\ngo get github.com/google/uuid 再在代码里导入并使用：\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;github.com/google/uuid\u0026#34; ) func main() { id := uuid.New() fmt.Println(id) } 此时 go.mod 可能包含：\nmodule example.com/user-api go 1.26.3 require github.com/google/uuid v1.6.0 改动依赖后执行：\ngo mod tidy go test ./... 第一条命令整理模块文件，第二条命令确认整个模块仍能编译并通过测试。到这里，一个 Go 项目从单文件走向模块化工程的基本骨架就搭起来了。\n","date":"2026-07-13T15:42:45+08:00","image":"/MyBlog/p/go-engineering-modules-project-structure/cover.svg","permalink":"/MyBlog/p/go-engineering-modules-project-structure/","title":"Go 语言工程化：模块管理与项目结构"},{"content":"一份 Go 入门语法梳理，从变量到指针，覆盖日常开发最常用的核心概念。\n变量声明 两种方法。\n1. var 标准形式 var name type = value // 标准形式 var pi = 22 // 可以自动推断类型，省略 type var score int // 可以不给 value 声明不赋值时，系统自动给零值。\n2. := 简短声明 name := \u0026#34;草泥马\u0026#34; 比较常见。限制：\n只能在函数里用（全局范围必须用 var） 不能只声明不赋值 常量与 iota 常量不可更改，直接用 const 声明：\nconst Pi = 3.14 const name = \u0026#34;xxx\u0026#34; 常量必须在编译期确定，不能把运行时的值赋给常量：\nconst now = time.Now() // ✗ 编译报错，time.Now() 是运行时才有的 iota 是 Go 在 const 块里提供的自动计数器：\nconst ( Sunday = iota // 0 Monday // 1 Tuesday // 2 Wednesday // 3 ) 比较简洁。想跳过某个值，用 _ = iota 占位就好。\n注意：Go 没有 enum 关键字，做枚举需要用 const + iota。\n数据类型 Go 是强类型语言，类型间不能自动转换（区别于 JS、Python），只能手动强制转换：\nvar a = 10 var b = 3.14 // 要算 a + b，必须先转类型 float64(a) + b 基本类型一览 var i int = 42 // 平台相关，64 位系统就是 64 位 var i8 int8 = 127 // 范围 -128 ~ 127 var u uint = 42 // 无符号，没有负数 var f32 float32 = 3.14 var f64 float64 = 3.14 // 一般都用 float64 var s string = \u0026#34;hello\u0026#34; var b bool = true // 只有 true/false，没有 1/0 rune 与 byte 的区别 var b byte = \u0026#39;A\u0026#39; // byte 就是 uint8，存储一个 ASCII 字符 var r rune = \u0026#39;中\u0026#39; // rune 就是 int32，存储一个 Unicode 字符 为什么要这样？英文字母只占一个字节，而中文、emoji 占 3~4 个字节，一个 byte 装不下，需要 rune。\ns := \u0026#34;hello\u0026#34; len(s) // 5，len 对 string 返回字节数 len([]rune(s)) // 5，返回字符数 想拿字符数就用 len([]rune(s))。原理：[]rune(s) 把 string 转成 []rune 切片。\n字符串的两种写法 s1 := \u0026#34;hello\\nworld\u0026#34; // 双引号，解释型：\\n 会被解释成换行 s2 := `hello\\nworld` // 反引号，原始字符串：字面上的 \\n 不会换行 切片 Slice 先说数组，但几乎不会用它：\nvar arr [3]int = [3]int{1, 2, 3} // 长度固定，声明时定死 所以实际开发用可变数组——切片。\nslice 声明 nums := []int{1, 2, 3} // 字面量 s := make([]int, 0, 10) // make 指定长度 0，容量 10 var names []string // nil，长度 0，后面 append len 与 cap 每个 slice 有两个属性：\nlen 长度：里面实际有多少个元素 cap 容量：底层数组最多能装多少个 s := make([]int, 3, 10) fmt.Println(len(s)) // 3 fmt.Println(cap(s)) // 10 append 添加元素 nums := []int{1, 2, 3} nums = append(nums, 4) // 末尾多了个 4 nums = append(nums, 5, 6, 7) // now [1,2,3,4,5,6,7] 注意：append 返回的是新的 slice，必须赋值回去。\n扩容：当 append 超过 cap 时，Go 会自动分配一个更大的底层数组，把旧数据拷过去。你不需要手动管，但要知道这个机制存在——它会影响性能。\n切片操作 nums := []int{10, 20, 30, 40, 50} sub := nums[1:3] // 左闭右开 → [20, 30] all := nums[:] // 全部 first := nums[:2] // [10, 20] last := nums[2:] // [30, 40, 50] ⚠️ 陷阱：切片出来的 sub 和原 slice 共享底层数组，修改 sub 会影响 nums：\nsub[0] = 999 fmt.Println(nums) // [10, 999, 30, 40, 50] —— nums 也被改了！ 要独立拷贝用 copy。\n遍历 fruits := []string{\u0026#34;apple\u0026#34;, \u0026#34;banana\u0026#34;, \u0026#34;cherry\u0026#34;} for i, v := range fruits { fmt.Printf(\u0026#34;[%d] %s\\n\u0026#34;, i, v) } // 不要索引就用 _ for _, v := range fruits { // ... } Map 其实就是映射。\n// 创建一个 map，key 是 string，value 是 int scores := map[string]int{ \u0026#34;math\u0026#34;: 90, \u0026#34;english\u0026#34;: 85, \u0026#34;chinese\u0026#34;: 92, } // 取值 fmt.Println(scores[\u0026#34;math\u0026#34;]) // 90 // 添加/修改 scores[\u0026#34;science\u0026#34;] = 88 // 新增 scores[\u0026#34;math\u0026#34;] = 95 // 修改（key 已存在就覆盖） // 删除 delete(scores, \u0026#34;english\u0026#34;) 长度用 len()。\nComma-ok 模式 取一个不存在的 key 会怎么样？一般直接返回 value 类型的零值。但这会出问题——0 是真实分数，还是因为 key 不存在？所以提供 comma-ok 模式：\nscore, ok := scores[\u0026#34;physics\u0026#34;] if ok { fmt.Println(\u0026#34;找到了：\u0026#34;, score) } else { fmt.Println(\u0026#34;不存在\u0026#34;) } // ok 是 bool 型变量 Map 的零值 var m map[string]int // nil map m[\u0026#34;a\u0026#34;] = 1 // 🙅 nil map 不能写入 和 slice 一样，声明之后必须初始化才能使用：\nm := map[string]int{} // 方式 1：空字面量 m := make(map[string]int) // 方式 2：make 遍历 scores := map[string]int{\u0026#34;math\u0026#34;: 90, \u0026#34;english\u0026#34;: 85} for subject, score := range scores { // 同时给 subject、score 赋值 fmt.Printf(\u0026#34;%s: %d\\n\u0026#34;, subject, score) } 结构体 Struct 结构体的知识在 C/C++ 中已经讲过很多，这里给个框架：\ntype Car struct { Brand string Color string Money float64 } 创建实例：\nvar c Car c.Brand = \u0026#34;特斯拉\u0026#34; fmt.Println(c.Brand) struct 与 map/slice 的区别 struct 是值类型，不同于 map、slice 底层数据共享。struct 可以直接赋值、修改元素，赋值时会拷贝一份完整数据。\nstruct 与 JSON 互转 这是实际开发中的高频操作。\nimport \u0026#34;encoding/json\u0026#34; func main() { c := Car{Brand: \u0026#34;特斯拉\u0026#34;, Color: \u0026#34;白色\u0026#34;, Money: 25.99} // 转成 JSON jsonData, err := json.Marshal(c) if err != nil { fmt.Println(\u0026#34;转换失败:\u0026#34;, err) return } fmt.Println(string(jsonData)) // string() 将字节数组转为可读字符串 } ⚠️ 问题：JSON 里的字段是大写的 Brand，但前端习惯小写。\n解决：struct tag，用反引号包裹：\ntype Car struct { Brand string `json:\u0026#34;brand\u0026#34;` Color string `json:\u0026#34;color\u0026#34;` Money float64 `json:\u0026#34;money\u0026#34;` } 这样序列化出来的 JSON key 就是小写了。注意反引号 ` 里面整个 json:\u0026quot;xxx\u0026quot; 是一个整体，冒号后面不能有空格。\n流程控制 三个核心：if/else、switch、for（Go 没有 while）。\nif/else Go 的 if 可以带初始化语句，用 ; 分隔：\nif age := 20; age \u0026gt;= 18 { fmt.Println(\u0026#34;成年人\u0026#34;) } // age 仅在 if 块内生效 ⚠️ 如果用到了 else，Go 强制要求 } else { 必须在同一行，不然编译报错：\nif condition { // ... } else { // ... } switch Go 的 switch 不需要写 break，每个 case 自动结束：\nday := \u0026#34;Monday\u0026#34; switch day { case \u0026#34;Monday\u0026#34;: fmt.Println(\u0026#34;星期一\u0026#34;) case \u0026#34;Tuesday\u0026#34;: fmt.Println(\u0026#34;星期二\u0026#34;) default: fmt.Println(\u0026#34;其他\u0026#34;) } for 循环 Go 只有 for，但有四种写法：\n1. 经典写法（和 C 一样）\nfor i := 0; i \u0026lt; 5; i++ { // ... } 2. 当 while 用（只有一个条件）\ni := 0 for i \u0026lt; 5 { i++ } 3. 无限循环（配合 break）\nfor { fmt.Println(\u0026#34;一直跑\u0026#34;) break } 4. range 遍历（实际开发中用得最多）\n// 遍历 slice nums := []int{10, 20, 30} for i, v := range nums { fmt.Println(i, v) } // 遍历 map scores := map[string]int{\u0026#34;张三\u0026#34;: 90, \u0026#34;李四\u0026#34;: 85} for k, v := range scores { fmt.Println(k, v) } range 后面可以只用一个变量接收值，用 _ 跳过不需要的：\nfor _, v := range nums { // 只要值，不要索引 } 注意：for 的三部分不用括号，但分号不能省。\n函数 Go 用 func 定义函数，函数必须定义在函数外。格式正常，重点看多返回值——这是 Go 的特色。\nfunc divide(a, b float64) (float64, error) { if b == 0 { return 0, fmt.Errorf(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } func main() { result, err := divide(10, 3) if err != nil { fmt.Println(\u0026#34;出错了:\u0026#34;, err) } else { fmt.Println(\u0026#34;结果:\u0026#34;, result) } } 几点注意：\nerror 是 Go 内置的接口类型，表示错误 没出错时返回 nil fmt.Errorf 创建一个带错误信息的 error Go 的错误处理模式就是 返回值 + if 判断，没有 try-catch。这在后面写项目时会反复见到。\n指针 核心概念：\n概念 符号 作用 取地址 \u0026amp;a 获取变量 a 的内存地址 解引用 *p 通过地址，读取或修改那个地址里的值 指针类型：\n普通类型 对应指针类型 读法 int *int 指向 int 的指针 string *string 指向 string 的指针 User *User 指向 User 的指针 指针的零值是 nil：\nvar p *int // p 的零值是 nil，没有指向任何东西 两个必须记住的事：\nGo 函数传参永远是复制。想改原件，必须传指针。 \u0026amp; 是\u0026quot;取地址\u0026quot;，* 是\u0026quot;去地址找东西\u0026quot;。两个方向相反。 直觉：普通变量是盒子里装着值，指针是纸条上写着\u0026quot;盒子在哪\u0026quot;。\n指针与 struct func birthday(u *User) { u.Age++ // Go 自动解引用，不用写 *u（语法糖） } func main() { user := User{\u0026#34;Jasper\u0026#34;, 22} birthday(\u0026amp;user) // 传地址 fmt.Println(user.Age) // 23 } Go 中局部变量习惯用小驼峰 maxStudent 而不是大驼峰 MaxStudent。import 用小括号 () 而不是花括号 {}。\n练习：学生成绩管理 一个综合 struct、slice、指针的小练习：\npackage main import \u0026#34;fmt\u0026#34; type Student struct { Name string Grade float64 } // 找出成绩最高的学生 func findMaxGrade(students []Student) Student { maxStudent := students[0] for _, student := range students { if student.Grade \u0026gt; maxStudent.Grade { maxStudent = student } } return maxStudent } // 给学生加 5 分（通过指针修改原件，不需要返回值） func plusFive(student *Student) { student.Grade += 5 } func main() { students := []Student{ {Name: \u0026#34;Alice\u0026#34;, Grade: 85}, {Name: \u0026#34;Bob\u0026#34;, Grade: 90}, {Name: \u0026#34;Charlie\u0026#34;, Grade: 78}, } maxStudent := findMaxGrade(students) plusFive(\u0026amp;maxStudent) fmt.Printf(\u0026#34;最高分学生: %s, 成绩: %.2f\\n\u0026#34;, maxStudent.Name, maxStudent.Grade) } 参考源 raw/go学习之路.md raw/go.md ","date":"2026-07-07T00:00:00+08:00","image":"/MyBlog/p/go-basics-variables-to-pointers/cover.svg","permalink":"/MyBlog/p/go-basics-variables-to-pointers/","title":"Go 语言基础：从变量到指针"},{"content":"承接 Go 语言基础：从变量到指针，这一篇覆盖 Go 的进阶核心概念：方法、接口、错误处理，以及泛型入门。\n方法 // 普通函数：参数是 Student func greet(s Student) string { return \u0026#34;Hello, \u0026#34; + s.Name } // 方法：绑在 Student 上 func (s Student) greet() string { return \u0026#34;Hello, \u0026#34; + s.Name } 区别就在于函数名前面多了一个 (s Student)，这叫接收者（receiver）。\n调用方式不同：\nstudent := Student{\u0026#34;Jasper\u0026#34;, 90} // 函数调用 greet(student) // 方法调用 student.greet() 返回一个字符串可以用 fmt.Sprintf。\n值接收者 vs 指针接收者 在接收者的类型前加 * 就变成指针接收者。值接收者只改副本，指针接收者改原件。\n// 值接收者：改的是副本 func (s Student) setScore(score float64) { s.Grade = score // 不影响原值 } // 指针接收者：改的是原件 func (s *Student) setScore(score float64) { s.Grade = score // 修改原值 } 接口 Go 中最重要的概念之一。\n// 定义接口：任何有 speak() 方法的类型，都算 Speaker type Speaker interface { speak() string } // Dog 有 speak() 方法，Dog 实现了 Speaker type Dog struct { Name string } func (d Dog) speak() string { return \u0026#34;Woof\u0026#34; } // 同理 Cat 亦如此 type Cat struct { Name string } func (c Cat) speak() string { return \u0026#34;Meow\u0026#34; } // 通用函数：接受任何 Speaker func makeItSpeak(s Speaker) { fmt.Println(s.speak()) } func main() { dog := Dog{\u0026#34;Buddy\u0026#34;} cat := Cat{\u0026#34;Kitty\u0026#34;} makeItSpeak(dog) // Woof! makeItSpeak(cat) // Meow! } 三个要点：\n接口定义：type Speaker interface { speak() string } — 定义了一个\u0026quot;能力\u0026quot;，不关心谁实现 隐式实现：Dog 和 Cat 都有 speak() 方法，所以它们自动\u0026quot;算\u0026quot;是 Speaker，不需要显式声明 implements 通用函数：makeItSpeak(s Speaker) 接受任何 Speaker，不关心具体是 Dog 还是 Cat 嵌入接口 小接口可以组合成大接口：\n// 两个独立接口 type Speaker interface { speak() string } type Walker interface { walk() string } // 嵌入组合 type SpeakerWalker interface { Speaker // 把 Speaker 的方法拿过来 Walker // 把 Walker 的方法拿过来 } 效果：SpeakerWalker 自动拥有 speak() 和 walk() 两个方法。\n接口哲学：小接口 \u0026gt; 大接口 Go 的接口是隐式实现的：\n// C++ / Java：必须显式声明\u0026#34;我实现了这个接口\u0026#34; class Person implements Speaker { ... } // Go：只要你有方法，就自动满足接口 type Person struct { Name string } func (p Person) speak() string { return \u0026#34;hi\u0026#34; } // Person 自动满足 Speaker，不需要声明 标准库里最常见的接口：\n// io.Reader — 只有一个方法 type Reader interface { Read(p []byte) (n int, err error) } // io.Writer — 只有一个方法 type Writer interface { Write(p []byte) (n int, err error) } Go 标准库里很多接口只有 1~2 个方法，而不是把所有功能堆在一个大接口里。\n为什么小接口好？\n容易实现 — 一个方法的接口，任何类型都能轻松满足 容易组合 — 用嵌入把小接口组合成大接口 灵活 — 你只关心你需要的能力，不关心具体类型 特性 Go Java/C++ 实现方式 隐式（自动满足） 显式（需要声明） 接口大小 通常 1-2 个方法 通常很多方法 设计哲学 小接口，按需组合 大接口，一次定义 io.Reader 与 io.Writer 这是 Go 标准库里最常用的两个接口，用来处理\u0026quot;数据流\u0026quot;。\n先想一个问题：从哪里读数据？\n从文件读 从网络读 从键盘读 从字符串读 这些地方都能读数据，但它们的实现完全不同。接口解决这个问题：\n// io.Reader：任何\u0026#34;能读出数据\u0026#34;的东西 type Reader interface { Read(p []byte) (n int, err error) } 翻译成大白话：\n\u0026ldquo;你只要有 Read 方法，能往 p 里填数据，你就是个 Reader\u0026rdquo;\n参数/返回 含义 p []byte 一个空的容器（桶），你把读到的数据放进去 返回 n int 实际读了多少字节 返回 err error 读取过程中有没有出错 为什么这个接口牛？因为它只有一个方法，所以实现简单、使用广泛——标准库里几百个函数都接受 Reader。\n// 这些类型都实现了 io.Reader // - os.File （文件） // - strings.Reader （字符串） // - bytes.Buffer （字节缓冲区） // - net.Conn （网络连接） // 通用函数：不关心数据从哪来，只管读 func printAll(r io.Reader) { data := make([]byte, 100) n, _ := r.Read(data) fmt.Println(string(data[:n])) } // 从文件读 file, _ := os.Open(\u0026#34;test.txt\u0026#34;) printAll(file) // 从字符串读 str := strings.NewReader(\u0026#34;hello\u0026#34;) printAll(str) 同一个函数，能处理不同类型的数据源——这就是接口的价值。\n接口 方法数 作用 io.Reader 1 能读 io.Writer 1 能写 io.Closer 1 能关闭 io.ReadWriter 2 能读能写（嵌入组合） Go 的设计哲学：把大能力拆成小接口，按需组合。\n空接口 any 与类型断言 有时候需要处理\u0026quot;任意类型\u0026quot;，这时候用空接口：\n// any 是 interface{} 的别名 func printAnything(v any) { fmt.Println(v) } 但是有个问题：拿到的是 any，不知道具体的类型怎么办？此时引出类型断言与 type switch。\n类型断言 func printAnything(v any) { if s, ok := v.(string); ok { fmt.Println(\u0026#34;是字符串:\u0026#34;, s) } else if n, ok := v.(int); ok { fmt.Println(\u0026#34;是整数:\u0026#34;, n) } else { fmt.Println(\u0026#34;其他类型:\u0026#34;, v) } } Type Switch 更简洁的写法：\nfunc printAnything(v any) { switch val := v.(type) { case string: fmt.Println(\u0026#34;是字符串:\u0026#34;, val) case Student: fmt.Println(\u0026#34;是学生:\u0026#34;, val) } } v.(type) 是 Go 的特殊语法，只能在 switch 里用，意思是\u0026quot;告诉我 v 的实际类型是什么\u0026quot;。val 会自动变成对应分支的类型。\nComma-Ok 模式详解 这是 Go 里处理\u0026quot;可能失败的操作\u0026quot;的通用模式，类型断言只是其中一个用法。\ns, ok := v.(string) 尝试把 v 转成 string 如果成功：s = 转换后的值，ok = true 如果失败：s = 空字符串零值，ok = false 关键点：如果不写 ok，断言失败会直接 panic（程序崩溃）。所以一般都用 , ok 安全地处理：\n// 不安全：失败会 panic s := v.(string) // 安全：失败只是 ok = false s, ok := v.(string) 接口 vs Type Switch 方式 优点 缺点 接口 扩展性好，加新类型不用改函数 需要提前定义接口 Type Switch 直观，能处理任意类型 加新类型要改函数 错误处理 strconv 是 Go 标准库里专门处理字符串和数字之间转换的包。strconv.Atoi = ASCII to Integer。\nGo 的错误处理模式：\nresult, err := someFunction() if err != nil { // 处理错误 return ..., err // 往上层传 } // 正常使用 result 核心规则：\n错误是普通值，函数返回 error 接口 nil = 没错，非 nil = 有错（带错误信息） 函数只返回错误，调用方决定怎么处理 error 接口 error 是 Go 内置的接口，只有一个方法：\ntype error interface { Error() string } 任何实现了 Error() string 方法的类型，都算 error。\n创建自己的错误 使用 errors.New 或 fmt.Errorf：\nimport \u0026#34;errors\u0026#34; func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New(\u0026#34;除数不能为零\u0026#34;) } return a / b, nil } 或者用 fmt.Errorf 带格式化：\nreturn 0, fmt.Errorf(\u0026#34;除数不能为零：%f\u0026#34;, b) 错误包装 有时候你想在错误上加点上下文，但是不想丢失原始错误：\nfunc readConfig(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { // 包装原始错误，加上上下文 return \u0026#34;\u0026#34;, fmt.Errorf(\u0026#34;读取配置文件失败：%w\u0026#34;, err) } return string(data), nil } 然后用 errors.Is 判断错误类型：\ncontent, err := readFile(\u0026#34;不存在的文件.txt\u0026#34;) if err != nil { fmt.Println(\u0026#34;错误:\u0026#34;, err) if errors.Is(err, os.ErrNotExist) { fmt.Println(\u0026#34;原因：文件不存在\u0026#34;) } } 设计理念：错误处理就像\u0026quot;层层汇报\u0026quot; 想象一个公司出了问题：\n员工：硬盘坏了（原始错误） 主管：服务器出问题了（包装一层） 经理：系统故障（再包装一层） 如果只传一句话：经理只知道\u0026quot;系统故障\u0026quot;，不知道根本原因，没法针对性解决。\n如果层层包装但保留原始原因：经理收到 → 系统故障 → 服务器出问题 → 硬盘坏了。经理可以用 errors.Is 查问：\u0026ldquo;是不是硬盘的问题？\u0026quot;——穿透所有包装，直接找到根本原因。\n代码对应：\n// 底层：硬盘坏了 os.ReadFile(\u0026#34;xxx\u0026#34;) // 返回 os.ErrNotExist（文件不存在） // 中层：服务器出问题了 fmt.Errorf(\u0026#34;读取文件失败: %w\u0026#34;, err) // 包装，但保留原始错误 // 顶层：经理 if errors.Is(err, os.ErrNotExist) { // 穿透包装，发现根本原因是\u0026#34;文件不存在\u0026#34; fmt.Println(\u0026#34;原因：文件不存在\u0026#34;) } 三种方式对比：\n// 方式1：只传原始错误（不好） return err // main 只知道 \u0026#34;no such file or directory\u0026#34;，不知道是哪个文件 // 方式2：只传新错误（不好） return errors.New(\u0026#34;读取文件失败\u0026#34;) // main 不知道为什么失败 // 方式3：包装（正确） return fmt.Errorf(\u0026#34;读取文件失败: %w\u0026#34;, err) // main 既知道上下文，又能判断原因 Sentinel Errors（哨兵错误） 就是预先定义好的错误值，让我们可以用 errors.Is 判断。os.ErrNotExist 就是一个 Sentinel Error。自己也可以定义：\nvar ErrInvalidAge = errors.New(\u0026#34;年龄不能为负数\u0026#34;) func setAge(age int) error { if age \u0026lt; 0 { return ErrInvalidAge } return nil } // 调用方 err := setAge(-1) if errors.Is(err, ErrInvalidAge) { fmt.Println(\u0026#34;年龄不合法\u0026#34;) } panic 与 recover Go 中大部分用 error 处理错误，但有些情况下程序直接崩溃——panic。\npanic：程序崩溃 recover：捕获 panic，让程序继续运行 什么时候用 panic 程序遇到无法继续运行的严重错误：\nfunc divide(a, b int) int { if b == 0 { panic(\u0026#34;除数不能为零\u0026#34;) // 程序崩溃 } return a / b } 什么时候用 recover 在服务器程序里，你不想因为一个请求崩溃就让整个服务器挂掉：\nfunc safeDivide(a, b int) (result int, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf(\u0026#34;捕获 panic: %v\u0026#34;, r) } // %v 是 Go 里最通用的格式化动词，意思是\u0026#34;用默认格式打印值\u0026#34; }() return divide(a, b), nil } 几个要点：\n命名返回值：(result int, err error) — Go 允许给返回值起名字，函数内部可以直接用这些名字，return 时可以省略返回值 defer：延迟执行，函数结束的时候才运行 recover：捕获 panic 的唯一方式，只能在 defer 里用 // defer 里的匿名函数：定义后立即调用 defer func() { if r := recover(); r != nil { // r 是 panic 传出来的信息 fmt.Println(\u0026#34;捕获到 panic:\u0026#34;, r) } }() // 定义完立刻调用 泛型（了解即可） 为什么需要泛型？ 没有泛型时，同一个逻辑要写多份：\nfunc maxInt(a, b int) int { if a \u0026gt; b { return a } return b } func maxFloat(a, b float64) float64 { if a \u0026gt; b { return a } return b } 逻辑完全一样，只是类型不同。泛型用一个函数搞定：\nfunc max[T int | float64 | string](a, b T) T { if a \u0026gt; b { return a } return b } 泛型函数语法 func 函数名[类型参数 约束](参数) 返回类型 拆解 func max[T int | float64 | string](a, b T) T：\nfunc max → 函数名 [T int | float64 | string] → T 可以是 int、float64 或 string (a, b T) → a 和 b 都是 T 类型 T → 返回类型也是 T 调用时 Go 会自动推断 T 的类型：\nmax(1, 2) // T = int max(1.5, 2.5) // T = float64 max(\u0026#34;hello\u0026#34;, \u0026#34;world\u0026#34;) // T = string 类型约束 约束就是告诉编译器\u0026quot;T 必须是什么类型\u0026rdquo;。\n常用约束：\n[any] → 任意类型 [comparable] → 可以用 == 比较的类型 [constraints.Ordered] → 可以用 \u0026lt; \u0026gt; 比较的类型（int、float64、string） 自定义约束：\ntype Number interface { int | int32 | int64 | float32 | float64 } func sum[T Number](nums []T) T { var total T for _, n := range nums { total += n } return total } 泛型类型 泛型还能用在 struct 上：\ntype Stack[T any] struct { items []T } func (s *Stack[T]) push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) pop() T { item := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return item } // 使用 intStack := \u0026amp;Stack[int]{} intStack.push(1) intStack.push(2) fmt.Println(intStack.pop()) // 2 strStack := \u0026amp;Stack[string]{} strStack.push(\u0026#34;hello\u0026#34;) fmt.Println(strStack.pop()) // hello 参考源 raw/Go语言.md ","date":"2026-07-07T00:00:00+08:00","image":"/MyBlog/p/go-methods-interfaces-errors/cover.svg","permalink":"/MyBlog/p/go-methods-interfaces-errors/","title":"Go 语言进阶：方法、接口与错误处理"},{"content":" 此篇致敬 Thoughts Memo《学会如何学习》，salute to 最伟大的互联网精神。这不是一篇反思文，而是一次复用。\n迭代 学习是一个强个性化的终身过程。不必等到找到\u0026quot;正确方法\u0026quot;再出发——寻找能进化你学习方式的线索，去尝试，去犯错，重复这个过程本身就是在完善它。\n学习模式本无高下，习得的内容会替我们说话。所以大胆尝试：深度结合 AI 也好，把知识抽象成不同模块也好，不要因为路径\u0026quot;非主流\u0026quot;就畏缩。最值得的上行风险，往往藏在别人觉得麻烦的地方。\n内省 球感、语感、直觉——这些抽象在知识体系之外的东西，占据了学习中相当重要的部分。尊重自己内部的感知信号，它们往往比理性分析更早发现问题所在。\n感到困惑或惊讶时，follow 这种情绪——它在指引你投入精力。 困惑和意外，正是对薄弱点的非理性感知在发光。 困惑时不要硬撑，向他人或 AI 求助。 如果觉得某个结论\u0026quot;似乎没什么适用边界\u0026quot;，往往是忽略了关键前提。 如果感知到\u0026quot;这东西空洞无用\u0026quot;，多半是在纸上谈兵——去实践，亲手弄懂它。 觉察那些\u0026quot;一头雾水、某个环节卡住\u0026quot;的感觉，找到那个 key。这是最重要的事。 觉察你对什么乐在其中，不断深挖，那里有充分的知识扩展和真实的乐趣。 二八法则 我们被庞大的信息流包裹，自媒体时代更甚。知识唾手可得，但质量参差不齐。高效学习的前提，是有能力取舍——识别出如何用 20% 的努力获取 80% 的核心价值。\n遇到一个新知识点，不妨先问自己：\n这里到底在讲什么？我的目标是什么？ 我为什么要关心这个？ 如果要从零推导，路径是什么？ 哪里让我感到不快或困惑？ 学会放弃同样是一种能力。不确定某个方向是否值得投入时，直接问 AI——比自己纠结半小时更高效，也更诚实。\n教学相长 不把\u0026quot;教学\u0026quot;理解成狭义的站在讲台上，而是一种更抽象的行为模式——复用。\n知识的流转路径大致如此：从不同来源获取信息，以语言为媒介吸收理解，转化为抽象的概念储存在神经元中；遇到合适场景时，再将这一过程逆向输出。这个长链路不可避免地会产生漏洞，而漏洞往往正是困惑和意外出现的时刻。\n教学，是这一逆向工程最极致的体现。当你发现某件事对他人讲不清楚，那就是需要再次强化、重新复用的信号。这篇博客本身，也是复用的一部分。\n向他人解释一个概念，是发现自己到底懂没懂的最快方式。\n间隔重复 一个被严重低估的具体技巧。记忆信息最高效的方式，不是反复阅读，而是主动提取——反复接受测试。测试次数越多，遗忘所需的时间就越长，且复习间隔会指数级拉开，全程记忆保持稳固。\nAnki 这类工具已经把这套机制做得很好用了。死记硬背名声不佳，但它有真实价值：确保重要知识能信手拈来，而不是每次都要去翻查。\n把一个知识熟稔于心和需要去查找它之间的差距，远比看起来大——因为它消除了摩擦力。而摩擦力，是行动、创造和进步的重大障碍。\n不过有一点要清醒：间隔重复不适合用来理解知识，它的理想用例是那些你已经有高层次理解的东西——用它来确保那些东西不会丢。\n后记 当知识廉价到只需发出疑问便可收获，对知识的筛选、处理与加工能力，反而变得更加稀缺和珍贵。AI 时代对人的变革与分化，我一次次感受得到。\n这是最好的时代，这是最坏的时代。\n","date":"2026-06-01T00:00:00+08:00","image":"/MyBlog/p/how-to-learn/cover.svg","permalink":"/MyBlog/p/how-to-learn/","title":"学习这件事"},{"content":"近日在学习CS61A，起初的学习是通过看视频课程，做对应lab，project，以求完善知识，但是很快发现视频太过于冗长，虽然保留了最生动的思维模式，可是对于想要快速获得领会其中知识的人来说，无疑是个煎熬，那么此时我便把目光转向了官方的教程文档，文档比起视频精简许多，文字的知识传播效率非常高（前提是你能充分理解）但文档的规整性迫使遣词造句非常复杂化，在学习进度的压迫下，我们无法慢慢啃骨头，所以就会引入ai的帮助。\nAI、LLM、Agent 的区分 无论是博主本身还是身边的人都会出现基础的混淆：\nAI：是一个泛称，任何能让机器表现的像智能的技术 LLM：本质类似函数，接受 token 序列，输出下一个 token 的概率分布，训练完成后权重被固定 Agent：在 LLM 下套了系统，包括工具调用、记忆、规划 在此之下还引入了另一个概念——Token：其实就是 LLM 处理文本所接受的最小单元，任何语言图片符号均会被转化成 token 输入给 LLM。\nAI辅助的学习闭环陷阱 我通常使用 Claude 帮助，把一大段艰难晦涩的知识点喂给他，而后阅读他精简的版本，获得最好的理解，在此之后，我会让 Claude 输出讲解的 md 文件放在我固定的 vault 文件夹下。\n但是整个逻辑在此闭环了，发现问题所在了吗？\n从始至终，我的知识理解仅限一个环节，就是 ai 的讲解，我确认我理解，然后过。记忆的过程原本是跟着啃文档的长时间生跟发芽的，而现在呢，知识被解耦成易于理解的部分后，时间成本的投入降低，进而导致知识的留存度过低。\n这件事不仅仅限于看课程，当做对应的 lab，project 时，遇到英文，或是不会的太常见了，现在就会进入求助 ai，ai 引导，简化知识，最后你负责填写了一些关键代码思路的空，感觉自己动了，实则当时间的维度被拉长到数天，甚至不用数个月，就会发现，这都是错觉。\n解法 那应该怎么去解决这个问题呢？详见对学习的反思篇博客，再次简单的概述：\n我觉得学习的逻辑线收束必定要有输出，无论是自己写一遍代码，或者是自己写一篇习得内容的博客，这本身就是对信息的强化。对于必要的值得记忆的信息，还应该借助 Anki 去按周期复习，才能获得最好的成效。\n在 AI 时代无疑提供了很大的学习便利，但是所谓 AI 幻觉的产生，本质是对快速获取知识红利的追偿。\n","date":"2026-05-31T00:00:00+08:00","image":"/MyBlog/p/ai-learning-illusion/cover.svg","permalink":"/MyBlog/p/ai-learning-illusion/","title":"对于AI辅助学习的见解——AI错觉的拨乱"},{"content":"第一篇博文 每次遇到这种有些重大意义的，为自己作结抑或为自己做首的时候，总是想的多，落到笔头上就寥寥无几了。\n谢谢这一路以来遇到的朋友，老师，家人，是你们给了我经历美好的勇气\n","date":"2026-05-12T20:00:00+08:00","image":"/MyBlog/p/hello-world/cover.svg","permalink":"/MyBlog/p/hello-world/","title":"JasperSao的首篇博文"}]