news 2026/10/2 6:13:40

深入理解Linux IO多路转接:select、poll与epoll核心原理与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解Linux IO多路转接:select、poll与epoll核心原理与实战

上一篇文章里我写到了非阻塞式 socket 在单线程里的应用,评论区就有兄弟问:非阻塞加轮询,连接一多不照样把 CPU 烧穿吗?每次 read 都返回 EAGAIN,循环里全是空转,这跟阻塞模型岂不是半斤八两?这个问题问到点子上了,它正好引出 Linux 网络编程里绕不过去的一块内容:IO 多路转接,也就是 select、poll、epoll 这一族 API。今天这一篇,我打算把这三兄弟从头到尾讲清楚,从“为什么需要它”到底层数据结构,再放一个能直接编译运行的 epoll 回显服务代码,最后把我这些年踩过的坑一并列出来。搞懂这一篇,你对 Linux 下高并发网络服务的理解会完整一大截。

这篇适合正在学 socket 编程的学生、准备 Linux 面试的求职者,还有工作中要写 TCP 服务但不想直接套框架、想自己掌控事件循环的后端开发。全程不依赖第三方库,纯 Linux C 接口,装好 gcc 和一个终端就能跑。

1. 先搞清楚一件事:IO多路转接到底要解决什么问题

很多人在一开始写网络服务时,都是从最简单的阻塞模型开始了。单线程 accept 一个连接,然后 read 数据,处理完再 read。这个模型只有一条路走到黑,一旦第二个客户端发起连接,服务端就彻底傻眼了,因为线程还没有从第一个连接的 read 调用里回来。阻塞 IO 的本质问题,是把“等待数据”和“读取数据”两件事绑死在了一起,而操作系统在数据到位之前,会把当前线程直接休眠。你手上握着几十个客户端,却只能在同一时间照顾一个,这就是单线程阻塞模型的死穴。

也许你马上想到,那多开几个线程总行了吧。小规模确实可以,几十个连接就开几十个线程,编译运行都很顺利。但连接数一旦往上抬,问题就变味了。每个线程默认有 8MB 左右的虚拟内存栈空间,线程切换要保存恢复上下文,线程之间一旦共享状态就得加锁,而加了锁又会有竞争和优先级反转。更讽刺的是,如果你的连接大部分时间都在“空闲等待”,那么这些线程基本都在阻塞休眠,真正干活的没几个。我见过不少线上服务,连接数到五万以后线程模型直接崩盘,不是这里超时就是那里锁等待。所以多线程不是不能写,而是扛不住大规模连接条件下的扩展性。

那 IO 多路转接到底做了件什么事?多路,指的是同时监控大量 socket;转接,指的是把“谁来告诉我数据到了”这件事,从用户态转接给内核去完成。进程不需要反复去每个 socket 上试探,而是把所有关心的 fd 一次性交给内核,内核发现有数据、可写、出错等情况后,再返回给用户。用日常的事来类比就是:你等病房里五个病人,不需要隔几分钟挨个推门问“好没好”,护士台有叫号系统,病人可接待了会主动广播。select、poll、epoll 就是那个护士台,socket 就是病房,事件就是叫号声。

2. select 与 poll:由内核代查的旧时代方案

2.1 用 select 写出第一个多路转接 Demo

select 是最早进入系统调用层面的多路转接方案。它的核心思路是用三个 fd_set 作为参数分别表示“可读”“可写”“异常”。fd_set 本质上不是数组,而是一个位图,每一个 bit 代表一个文件描述符,Linux 下 FD_SETSIZE 默认 1024,这意味着默认最多只能监听 1024 个 fd。这是第一个需要刻在脑子里的认知。

先看一段 select 的最关键片段:

fd_set readfds; struct timeval tv = {5, 0}; while (1) { FD_ZERO(&readfds); FD_SET(listenfd, &readfds); // 把每个需要关注的客户端 fd 用 FD_SET 加进去 int ret = select(maxfd + 1, &readfds, NULL, NULL, &tv); if (ret < 0) { perror("select"); break; } if (ret == 0) { printf("5秒超时,没有任何fd就绪\n"); continue; } if (FD_ISSET(listenfd, &readfds)) { int connfd = accept(listenfd, NULL, NULL); printf("new connection, fd=%d\n", connfd); } // 再遍历其他已连接fd,用FD_ISSET判断谁可读 }

注意几个细节。select 的第一个参数 nfds 不是 fd 总个数,而是“最大 fd 编号 + 1”,因为内核是线性遍历 0 到 nfds 之间所有位图的,传错了就会漏掉高编号 fd。select 会修改传入的 fd_set,下次调用前必须重新初始化并重新添加所有 fd;timeval 也会被内核修改成剩余时间,循环里必须重新赋值。这些都是新手最容易踩的雷。

2.2 select 的三大硬伤

select 的实际工程价值,在小 demo 里能体现出来,但一上规模就难受了。第一,1024 的上限,在你服务端要承接几万连接的时候,这根本不够看。第二,每次调用都要把整个 fd_set 从用户态拷贝到内核态,然后内核再从 0 到 maxfd 全部扫描一遍,这个 O(N) 的时间不会因为活跃连接少而减少。第三,select 返回后,你只知道“有几个就绪”,却不知道“哪几个就绪”,必须自己重新遍历一遍 fd_set 才能确定到底是哪个 socket 有数据。也就是说,用户态还要再做一次 O(N) 的扫描。结合后面的 epoll 再看,select 的问题不只是性能问题,而是它把所有代价都花在了与并发规模成正比的地方,完全没有可扩展性。

2.3 poll 的改进与它没有根治的问题

poll 是 select 的修正版,它用 pollfd 数组替代位图,彻底去掉了 1024 的上限,而且 events 和 revents 分离:你只需要设置 events,内核返回时填 revents,同一个 pollfd 结构可以在循环里反复复用,不需要像 fd_set 那样每次全部重建。

struct pollfd fds[MAX_CONN]; fds[0].fd = listenfd; fds[0].events = POLLIN; while (1) { int ret = poll(fds, nfds, -1); if (ret < 0) { perror("poll"); break; } if (fds[0].revents & POLLIN) { int connfd = accept(listenfd, NULL, NULL); fds[nfds++].fd = connfd; fds[nfds - 1].events = POLLIN; } for (int i = 1; i < nfds; i++) { if (fds[i].revents & POLLIN) { // 处理该连接的数据 } } }

poll 看起来比 select 优雅,但它没有根治性能问题。每次调用仍然要把整个 pollfd 数组从用户态拷贝到内核态,内核还是线性遍历所有 fd,返回后用户态也还是要把整个数组过一遍。复杂度没有量级上的变化,只是常数和上限变好了。所以 poll 适合连接数量中等、活跃连接占比不低的场景,一旦连接数量升到很高,它和 select 一样会变得非常吃力。

3. epoll:性能与可扩展性的关键转折

3.1 epoll 的三个系统调用怎么配合

epoll 之所以能成为 Linux 下高并发的事实标准,是因为它从 API 设计上就跟 select/poll 不一样。select 把“要监控的东西”放在参数里每次传,epoll 则把“要监控的东西”维护在内核里,用户通过三个函数来增删改查。

  • epoll_create1(0):在内核创建一块事件表,返回一个文件描述符。旧版epoll_create(1024)那个参数在 Linux 2.6.8 之后已经只是历史遗留提示,传多大内核都不再拿它当容量上限。
  • epoll_ctl(epfd, op, fd, &event):操作事件表,op 可取 EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL,分别表示注册、修改、删除某个 fd 的监听事件。
  • epoll_wait(epfd, events, maxevents, timeout):阻塞等待事件,返回就绪事件个数,events 数组里存的是就绪的 fd 和事件类型。你不需要自己遍历“全部 fd”,只需要处理返回的这几个。

一个基础流程是:先创建监听 socket,加进 epoll;客户端连接到来时,accept 拿到新 fd,再把这个 fd 加进 epoll;之后每次 epoll_wait 返回,就知道哪些 socket 上有数据到了,逐个处理即可。

struct epoll_event ev; int epfd = epoll_create1(0); ev.events = EPOLLIN; ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { // 处理 events[i].data.fd 上的事件 } }

3.2 就绪回调与红黑树,epoll 快的根本原因

epoll 快的秘密藏在两个内核数据结构里:一张红黑树,一条就绪链表。红黑树用于维护当前注册的所有 fd,因此增删查 fd 的复杂度控制在 O(logN),这个开销只发生在调用epoll_ctl的时候。更关键的是,当你通过epoll_ctl把 fd 挂进事件表时,内核会给该 fd 对应的文件对象注册一个回调函数。一旦这个 fd 上有事件发生,比如网卡收到数据、socket 变为可写,内核就直接调用这个回调,把该 fd 塞进就绪链表。

这样一来,epoll_wait返回前只做一件事:从就绪链表里摘出已经就绪的节点,复制到用户态传入的 events 数组。内核不需要从头到尾扫描全部 fd,事件通知的复杂度只跟“就绪的连接数”成正比,而不是跟“连接总数”成正比。理解这一点,你就明白为什么 epoll 能轻松扛住几十万连接:大部分空闲连接既不占用就绪链表,也不需要被反复扫描。这就好比叫号系统并不是把整栋楼的病人全扫一遍才告诉你几号能就诊,而是每个病人完成检查时主动挂号通知。

3.3 ET 与 LT:边沿触发和水平触发怎么选

epoll 在支持 EPOLLIN、EPOLLOUT 这些事件类型之外,还有一个影响行为的关键标志:边沿触发(Edge Triggered,ET)和水平触发(Level Triggered,LT)。默认是 LT,只要 fd 上还有数据没读干净,每次 epoll_wait 都会持续返回该事件;ET 则完全不同,事件只在状态变化的那一刻通知一次,如果你没有把数据读完,内核不会再提醒,直到这个 fd 上再次发生状态变化,比如又有新的数据到达。

拿门铃类比会很直观:LT 是只要门口有人,你每次开门它都会响;ET 是门铃只响一次,你必须趁这一下把门口的东西全部搬进去,否则就错过了。ET 的高效之处在于它显著减少了 epoll_wait 的重复返回,但代价是编程难度变高。使用 ET 模式时,必须把 socket 设置为非阻塞,数据到达后要用循环 read 一直读到返回 EAGAIN,才能保证数据清空,否则残留数据会导致事件漏报,连接服务进入“死等”状态。我在实际项目里,对业务逻辑复杂、不可控读时长的场景更倾向于用 LT,对纯转发、收发逻辑简单清晰且性能要求高的场景用 ET。新手入门建议先跑通 LT,再挑战 ET,不要一上来就追求极端性能。

4. 实操:一个完整的 epoll 回显服务与测试记录

4.1 完整服务端代码与核心注释

我把这些年常用来验证环境的最小可运行模型贴出来,这是一个 ET 模式的回显服务,客户端发什么它就回什么。代码不长,但把 epoll 的三个接口、非阻塞设置、循环读数据这几件事全串起来了。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <fcntl.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/epoll.h> #define MAX_EVENTS 1024 #define BUFFER_SIZE 4096 static int set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags < 0) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd < 0) { perror("socket"); return 1; } int reuse = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8888); if (bind(listenfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listenfd, 128) < 0) { perror("listen"); return 1; } int epfd = epoll_create1(0); if (epfd < 0) { perror("epoll_create1"); return 1; } struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); struct epoll_event events[MAX_EVENTS]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; } for (int i = 0; i < n; i++) { if (events[i].data.fd == listenfd) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); int connfd = accept(listenfd, (struct sockaddr *)&client_addr, &len); if (connfd < 0) { perror("accept"); continue; } printf("new connection from %s:%d, fd=%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), connfd); set_nonblock(connfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev); } else if (events[i].events & EPOLLIN) { int fd = events[i].data.fd; char buf[BUFFER_SIZE]; int len; while ((len = read(fd, buf, sizeof(buf))) > 0) { if (write(fd, buf, len) < 0) { break; } } if (len == 0) { printf("connection closed, fd=%d\n", fd); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } else if (len < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK) { perror("read"); close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } } } } } close(epfd); close(listenfd); return 0; }

这份代码里有几个位置必须解释一下。第一,为什么要非阻塞?因为这里的连接 fd 注册的是 EPOLLET,边沿触发下一次不再重复通知,必须一次性把缓冲区读干净,如果 socket 是阻塞的,最后一次 read 会卡死整个事件循环。第二,为什么 read 要用循环?其实严格说,当 EPOLLET 触发时,只要还有数据,read 返回就是正数,直到缓冲区为空才返回 EAGAIN,所以用 while 循环才能榨干缓冲区的全部内容。第三,len == 0 表示对端正常关闭,要在条件判断里最先处理,因为这是判断连接断开最可靠的方式。

4.2 编译、运行与多客户端测试

编译和运行只需要三条命令:

gcc -o echo_epoll echo_epoll.c ./echo_epoll

程序启动后监听 8888 端口。客户端我建议直接开几个终端分别跑 nc,最省事:

nc 127.0.0.1 8888

一个终端输入 hello,另一个终端输入 world,你会在各自终端上看到回显内容,同时服务端终端会打印出新连接的来源地址和 fd 编号。如果你像我一样喜欢较真,可以用脚本模拟大量连接,比如写一个循环发起 1000 个 TCP 连接并保持住,再观察服务端日志和 CPU 占用率。实测下来,连接数从几百涨到几万,整个事件循环的稳定性和响应速度都远不是非阻塞轮询能比的。

我这边跑起来后,服务端输出大概是这个样子:

new connection from 127.0.0.1:51234, fd=6 new connection from 127.0.0.1:51236, fd=7 new connection from 127.0.0.1:51238, fd=8

同时可以另开一个终端用 lsof 看进程的 fd 数量增长情况:

lsof -p $(pgrep echo_epoll) | wc -l

随着客户端连接增加,这个数字会逐步上升,但 CPU 占用率始终保持低位。这正是 IO 多路转接的核心价值:它把系统的关注范围从“连接数量”缩小到了“实际活跃的连接数量”。

4.3 从运行观察看 epoll 的工作过程

如果你手边有 strace,还可以把 epoll_wait 的系统调用过程拉出来看一眼:

strace -p $(pgrep echo_epoll) -e trace=epoll_wait,accept,read,write

观察点在于:当没有任何客户端发数据时,epoll_wait 会一直阻塞在调用里;一旦有数据到达,read 返回正数,但 epoll_wait 本身并不会频繁被触发,因为 ET 模式下事件通知一次就没了,只要你把数据读干净了,内核不会再打扰你。这个打印结果能很直观地体现 LT 与 ET 的区别,也能帮你确认自己有没有漏读数据。我自己第一次用 ET 时,就因为 read 只调一次没循环,结果大量数据卡在缓冲区里,服务端完全不再触发事件,排查了半天才反应过来。

5. 实战中踩过的坑与排查速查清单

5.1 EAGAIN 和 EPOLLET 的配合

ET 模式下最典型的翻车现场是事件只触发一次,程序却只 read 了一次就退出了。数据量大一点的 TCP 报文,一个包根本塞不下,剩下的数据全部滞留在内核接收缓冲区里,而内核又不会第二次通知你,于是客户端明明发了消息,服务端却像死了一样毫无反应。解决方式是循环 read 直到返回 EAGAIN 或 EWOULDBLOCK,这两个 errno 值与 EAGAIN 完全等价,作用就是告诉你“缓冲区已清空,当前没有更多数据了”。注意不要对 EAGAIN 做 perror 打印或者当作错误处理,它是 ET 模式的正常退出条件,一旦打印日志刷屏,性能就毁了。

另外,这里有一个容易混淆的点:ET 模式和阻塞 fd 连用是不可靠的。因为当你循环 read 到缓冲区为空时,阻塞 fd 的下一次 read 会一直挂起等待,而不是立刻返回 EAGAIN,于是你的事件循环彻底卡死在读调用上。这也是我代码里特意用 fcntl 把每个新连接设置为非阻塞的根本原因。

5.2 连接关闭事件怎么判

最可靠的关闭判断永远是用 read 的返回值。read 返回 0,说明对端发送了 FIN,连接进入半关闭状态,这时候你该做的第一件事是清理自己这边的 fd 并调用 epoll_ctl 的 EPOLL_CTL_DEL。有人会依赖 EPOLLRDHUP 这个事件来提前感知对端关闭,但它只在特定内核版本和协议栈实现下稳定,而且一旦配合 TLS、代理等中间层,行为会变得不易掌控。还有 EPOLLHUP 和 EPOLLERR,它们在异常断开时会被返回,但你的事件循环不能只盯着 EPOLLIN 看,EPOLLOUT 和 EPOLLERR 位也要检查,否则 RST 之类的异常会被忽略,连接 fd 一直泄漏在 epoll 里。

有一年我在线上排查过一个问题:服务进程的 fd 数量一直涨,时不时出现“Too many open files”。最后定位到原因是事件循环里只判断了 EPOLLIN,没有充分处理 EPOLLERR 和 EPOLLHUP,客户端异常断电后,那个 socket 在 epoll 表里还挂着一个不活动的节点,既不会触发 EPOLLIN,也不会被主动清理。从那以后,我的事件循环判断都会优先检查 events[i].events & (EPOLLHUP | EPOLLERR),再做常规数据处理。

5.3 惊群与 EPOLLEXCLUSIVE

如果你的服务是用多线程或者多进程同时对一个 epoll fd 调 epoll_wait,当新连接到来时,所有等待线程都会被唤醒,但最终只有一个人能 accept 成功,其余线程白白空转一场,这就是惊群。早期 Nginx 也踩过这个坑,后来通过锁机制解决。Linux 4.5 之后引入了 EPOLLEXCLUSIVE 标志,可以在注册事件时让内核只唤醒一个等待者,显著缓解惊群。另外在 listener 层面使用 SO_REUSEPORT 让多个进程各自绑定同一端口,也是常见的分散压力方案。但这两个都是优化手段,不是新手阶段必须掌握的东西。你要记得的是:单线程事件循环天然没有惊群问题,能用单线程解决的场景,不要为了“高并发”的幻觉硬上多线程。

5.4 常见问题速查表

现象原因解决办法
epoll_wait 被信号打断,返回 -1进程收到信号,errno 为 EINTR判断 errno == EINTR 后 continue,不要 break 退出
ET 模式只有第一次收到数据read 没有循环读到 EAGAIN,数据残留在缓冲区循环 read,直到返回 EAGAIN/EWOULDBLOCK
ET 模式进程卡死,CPU 无输出socket 没有设为非阻塞,缓冲空时 read 阻塞注册前用 fcntl 设置 O_NONBLOCK
新连接一直不触发 acceptlistenfd 没有加入 epoll,或者事件类型没设 EPOLLIN检查 epoll_ctl 的注册对象和 events 设置
epoll_ctl ADD 返回 EEXIST同一个 fd 被重复加入改用 EPOLL_CTL_MOD 更新事件,或先 DEL 再 ADD
大量空闲连接导致 CPU 高事件循环里每个事件之间忙轮询epoll_wait 的 timeout 设 -1,依靠事件驱动,不要自己加 sleep 兜底
对端断开后 fd 泄漏只处理 EPOLLIN 不处理 HUP/ERR在事件判断中同时检查 EPOLLHUP、EPOLLERR,read 返回 0 立刻清理

6. 最后分享几个我个人的编码习惯

我在实际写这一类程序时,有个很固执的习惯:主事件循环里只做收发数据,绝不直接处理业务逻辑。收到完整报文后,把数据丢进队列,由其他线程专门去消费,或者至少把耗时操作移出 epoll_wait 的返回循环。原因很简单,epoll_wait 返回后,如果你在一个连接上卡了 10 秒做计算,所有其他连接的读写都会被拖死,多路转接的优势瞬间归零。这个思路跟 Reactor 模式是一致的,先把这一层想明白,后续学 libevent、libuv 甚至 Nginx 的事件模型,都会顺畅得多。

还有一个小技巧:新同学写 epoll 服务时,建议第一版先用 LT 模式,把事件循环的基本逻辑跑通,之后再改成 ET 并测试数据完整性。不要一上来就把两个难点绑在一起调试,那样出了问题你根本分不清是模式选择错了还是代码写错了。IO 多路转接只是解决“同时等很多 socket”这个问题的工具,它不负责业务架构,也不负责数据协议,把这些边界分清楚,你的代码才不会越写越复杂。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 6:13:07

Oracle数据库基础之9_RMAN备份恢复

备份按系统的准备程度分 冷备份:shutdown停机拷贝文件&#xff0c;不支持724业务 热备份:open状态下进行&#xff0c;支持724业务 备份按数据类型备份分 逻辑备份&#xff1a;exp、expdp 物理备份&#xff1a;rman、用户管理的备份[alter tablespace XX begin backupOS拷贝] RM…

作者头像 李华
网站建设 2026/10/2 6:13:06

西门子AF框架第十六章:工业PLC调度器原理与仿真调优实战

1. 这不是简单的文字搬运&#xff0c;而是一次工业软件本地化工程的实操复盘“西门子AF框架翻译-第十六章”——看到这个标题&#xff0c;很多刚接触TIA Portal博途生态的工程师第一反应是&#xff1a;又一本技术文档&#xff1f;翻完就扔&#xff1f;但如果你真这么想&#xf…

作者头像 李华
网站建设 2026/10/2 6:12:51

直启盘光纤/网络中继模块:可编程联动公式与工业控制实战

1. 直启盘光纤/网络中继模块到底是个什么东西第一次看到“直启盘光纤/网络中继模块”这个组合词&#xff0c;很多人会愣一下——直启盘是什么&#xff1f;光纤中继模块我懂&#xff0c;网络中继模块我也懂&#xff0c;但把这两个东西塞进一个带“可编程联动公式”的盒子里&…

作者头像 李华
网站建设 2026/10/2 6:12:30

Spec Coding 实战:用 TaoToken 统一 Key 打通 Cline MCP 的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 6:12:29

为什么选择ReadAny?对标Calibre/KOReader,9大核心功能一文看懂

为什么选择ReadAny&#xff1f;对标Calibre/KOReader&#xff0c;9大核心功能一文看懂 【免费下载链接】ReadAny AI-powered cross-platform e-book reader with semantic search, RAG chat, local vector store, notes, TTS, and WebDAV sync. 项目地址: https://gitcode.co…

作者头像 李华