线程池+epoll:构建高并发网络服务的关键骨架
1. 先想明白单线程模型到底堵在哪里1.1 一个连接一个线程的代价网络服务做到第二步很多项目会开始纠结一个问题accept 到新连接之后到底在哪条线程里处理读写最直觉的方案是“thread per connection”来一个连接就开一个线程在这个线程里阻塞读、处理业务、阻塞写。早期不少小项目这么写代码最简单逻辑最好懂。但连接数一旦往上走这套模式的短板会非常明显。先说线程创建。每次 accept 都创建线程这个开销在低频场景无所谓在短连接密集的场景会成为瓶颈。线程创建涉及内核分配任务栈、建立线程描述符、触发调度器操作一次创建的开销远大于普通函数调用。如果单个连接平均只存活几十毫秒那么服务端大量 CPU 时间都花在了“创建线程”和“销毁线程”上真正干活的占比反而很低。再说上下文切换。假设一台机器有 8 个物理核同时在线 500 个连接、每个连接一个线程那么操作系统需要在这 500 个线程之间来回切换。每次切换都要保存和恢复寄存器、刷新各种缓存状态CPU 的核心资源被调度器消耗掉一大块。连接数越多切换越频繁单条连接的延迟反而越不稳定。线程自身的资源开销也不可忽视。每个线程默认栈大小通常在 8MB 左右虽然这只是虚拟内存但大量线程会让地址空间碎片化也让内核的调度对象数量暴增。再加上每线程一套 errno、每线程一套本地存储内存压力其实不小。所以“来一个连接开一个线程”不是不能用而是适合连接数很少、单条连接存活时间很长的场景。一旦服务目标是高并发、短连接、突发流量就必须把“创建线程”和“处理连接”解耦让线程被反复复用。1.2 accept 与 worker 分离的本质把 accept 和 worker 拆开核心思想只有一句话accept 线程只负责接收新连接拿到连接描述符之后立刻丢给线程池自己不参与任何业务读写。为什么要这样拆因为 accept 本身是一个低频操作。无论一秒来一百个连接还是一万个连接相对每个连接上的多次读写来说accept 的工作量都很小。真正消耗 CPU 的是连接建立后的数据接收、协议解析、业务处理和数据发送。如果把 accept 和业务处理放在同一条线程里一旦某个连接出现慢客户端、半包、超时accept 线程就会被卡住后面的新连接全部在队列里排队。我在实际项目里见过一个非常典型的故障服务端用单线程阻塞式处理连接某天一个客户端建立连接后不读也不写只把连接挂着。服务端线程阻塞在 recv 上后续所有新连接都无法 accept健康检查也连不上最终整个服务被一条僵尸连接拖死。这个案例给我的教训是accept 路径必须和业务处理路径隔离accept 线程的等待时间必须可控。这个形态其实就是经典的“半同步/半异步”模型accept 线程是同步接收端线程池里的 worker 是异步处理端。accept 线程负责把连接分发给 workerworker 负责把连接上的数据读完、处理完、写完。两者之间通过任务队列衔接队列相当于一条流水线。拆开之后还有一个附带好处accept 线程可以保持非阻塞配合 epoll 监听 listen fd。这样服务在空闲时没有线程被阻塞在 accept 上停止服务时也可以通过 eventfd 主动唤醒 accept 线程实现优雅退出。相比之下阻塞式 accept 在 shutdown 时处理起来很别扭要么 shutdown 监听 fd要么等超时都容易出边角问题。2. 线程池基础设施先写一个不坑的 ThreadPool2.1 线程数物理核数不是唯一答案线程池接入的第一步不是写代码而是确定线程数。很多项目一拍脑袋定了 8 或者 16看似够用其实没把工作负载类型算进去。如果 worker 内部是同步阻塞读写也就是每个 worker 同时只能处理一个连接那么线程数应该和“期望同时活跃的连接数”相关而不是和核数相关。这种情况下线程数太少会导致大量连接排队延迟升高线程数太多会导致上下文切换加剧。如果 worker 内部用的是 epoll 事件循环也就是每个 worker 可以同时管理多个连接那么线程数的经验值通常取std::thread::hardware_concurrency()也就是物理核数或逻辑核数。因为每个 worker 是一个事件循环它在单位时间内能处理的连接数受 CPU 计算能力限制而不是受连接数限制。线程开得比核数多反而会因为调度切换损失性能。我在做骨架时习惯先按核心数开 worker然后通过压测逐步调整。如果业务中有大量磁盘 IO、第三方接口调用这类阻塞操作可以给每个 worker 内部再挂一层异步任务队列而不是简单粗暴增加线程数。线程数不是越大越好关键是让每个线程都有事干且切换成本可控。2.2 任务队列、唤醒机制与生命周期线程池的核心数据结构是一个任务队列外加一把互斥锁和一个条件变量。任务队列可以是std::deque或std::queue任务类型用std::functionvoid()包装。投递任务时加锁、入队、通知一个 workerworker 获取任务时加锁、等待、出队、执行。这里有一个关键点条件变量的等待必须配合谓词判断不能裸写cv.wait(lock)。原因是条件变量存在“虚假唤醒”的可能而且如果先 notify 后 wait线程可能错过唤醒信号。正确写法是std::unique_lockstd::mutex lock(mu_); cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });wait的谓词版本会在被唤醒后重新检查条件只有条件满足才继续往下走。这个细节非常重要很多新手写的线程池会出现“偶尔有任务不执行”的诡异现象多半就是漏了谓词判断。任务队列本身也有讲究。如果队列无界当连接创建速度大于 worker 处理速度时任务会无限堆积内存持续上涨最终触发 OOM。这是一个非常隐蔽的故障因为在压力测试初期可能不会爆发直到连接数达到某个临界点。好的做法是给队列设置上限比如 1024超过上限时 accept 线程可以短暂休眠或直接拒绝新连接形成背压。背压虽然会让 accept 变慢但总比内存被打爆要好。线程池的生命周期管理同样容易翻车。析构函数里必须先置stopping_为真再notify_all()然后逐个join()。顺序反了会导致死锁worker 在等任务主线程在等 worker 退出双方互等。下面是一个最小可用的线程池骨架核心逻辑都在可以直接抄来当基础版本class ThreadPool { public: explicit ThreadPool(size_t n) : stopping_(false) { workers_.reserve(n); for (size_t i 0; i n; i) { workers_.emplace_back([this] { WorkerLoop(); }); } } ~ThreadPool() { Stop(); } ThreadPool(const ThreadPool) delete; ThreadPool operator(const ThreadPool) delete; template typename F void Submit(F f) { std::lock_guardstd::mutex lock(mu_); tasks_.emplace(std::forwardF(f)); cv_.notify_one(); } void Stop() { { std::lock_guardstd::mutex lock(mu_); stopping_ true; } cv_.notify_all(); for (auto t : workers_) { if (t.joinable()) t.join(); } } private: void WorkerLoop() { for (;;) { std::functionvoid() task; { std::unique_lockstd::mutex lock(mu_); cv_.wait(lock, [this] { return stopping_ || !tasks_.empty(); }); if (stopping_ tasks_.empty()) { return; } task std::move(tasks_.front()); tasks_.pop(); } task(); } } std::mutex mu_; std::condition_variable cv_; std::queuestd::functionvoid() tasks_; std::vectorstd::thread workers_; bool stopping_; };2.3 Stop 和析构最容易翻车的地方上述代码的Stop()有个隐藏问题如果 worker 当前正在执行一个长期不返回的任务比如一个 epoll 事件循环那么join()会一直等下去。这个问题在后面接入 worker 事件循环时会集中暴露。处理办法有两种。第一种是要求所有任务都是短任务worker 执行完就返回这样 Stop 可以正常退出第二种是给每个长期任务一个显式的停止信号比如 epoll 循环里注册 eventfdStop 时向 eventfd 写入数据让 epoll_wait 返回。这里没有银弹必须根据任务类型区分。线程池本身不建议提供“强制杀死线程”的功能因为std::thread没有安全终止接口强行 terminate 会留下未释放的锁、未关闭的 fd、损坏的数据结构。设计上要保证任务能够被主动告知退出而不是从外部硬杀。3. accept 线程怎么写accept4 与连接分发3.1 非阻塞 accept 的正确打开方式accept 线程的第一版可以直接用阻塞式accept()简单省事。但为了后续优雅停止和统一事件模型我更推荐用非阻塞模式加 epoll 监听。Linux 下创建连接描述符时最好直接用accept4一步设置非阻塞和 CLOEXEC 两个标志。这样就不用先 accept 再调用fcntl省一次系统调用也避免了在多线程环境下 fd 被临时继承的窗口期。int conn accept4(listen_fd, (struct sockaddr*)addr, len, SOCK_NONBLOCK | SOCK_CLOEXEC);如果accept4返回 -1要分情况处理。EAGAIN或EWOULDBLOCK表示当前没有新连接是正常返回EINTR表示被信号中断可以继续循环EMFILE或ENFILE表示进程或系统的 fd 数量耗尽这是需要重点处理的情况。很多人一看到EMFILE就慌了直接在日志里打一行错误然后继续循环。但这里有一个很深的坑如果用 epoll ET 模式监听 listen fd当 fd 耗尽导致 accept 失败时这个监听 fd 上的可读事件可能再也不会触发。因为“新连接可接受”这个事件被丢弃了内核认为这个事件已经尝试告诉过应用程序不会重复通知。解决办法有两个一是单独预留一个空闲 fd在 accept 失败时先关闭它再 accept成功后再补回二是对 listen fd 使用 LT 模式避免事件丢失。这个细节我在后面故障排查里再展开。3.2 任务队列里放什么accept 线程拿到连接 fd 之后要决定往队列里放什么。最简单的方案是只放一个 int fdworker 拿到 fd 后自己创建连接对象、加入自己的 epoll。这种方案在连接对象模型简单时完全够用而且任务队列的拷贝成本极低。但要注意 fd 的所有权转移。fd 只是一个数字真正对应的是内核文件描述符表里的一项。accept 线程把 fd 交给 worker 后accept 线程就不能再碰这个 fd否则可能出现两个线程同时读同一个 fd、或者一个线程关闭 fd 另一个线程还在用的情况。提交任务时可以用 move 语义把连接对象封装进去比如用一个unique_ptrConnection代替裸 fd这样语义更清晰。如果你的 worker 是“每个 worker 一个 epoll 实例”那么 accept 线程在投递任务时还可以顺带决定这个连接归哪个 worker 管。最简单的分配策略是取模每 accept 到一个连接就给第(conn_count % worker_count)个 worker。这种策略实现简单在连接存活时间比较均匀时效果不错。但如果某些连接生命周期特别长取模会导致个别 worker 上堆积大量长连接其他 worker 闲着。更精细的做法是让每个 worker 暴露一个当前连接数或任务队列深度accept 线程每次选择负载最轻的 worker 投递这就是所谓的“最少连接调度”。3.3 accept 线程的边界职责accept 线程要克制不要顺手做太多事情。常见错误是在 accept 线程里读取客户端发来的第一个包、解析协议头、甚至初始化连接的超时定时器。这些操作都会增加 accept 线程的临界区长度让 accept 吞吐量下降。accept 线程的职责边界应该是接收新连接、设置必要标志、封装任务、投递到队列、然后立刻回到 accept 循环。任何可能阻塞的操作都不能出现在这条线程上比如 DNS 解析、磁盘读写、加锁竞争严重的业务逻辑。如果确实需要在 accept 阶段做初始握手比如 TLS 的 ClientHello那也应该把握手过程变成一个异步任务交给 worker而不是在 accept 线程里同步做。4. worker 线程怎么消费epoll 事件循环与连接状态机4.1 每个 worker 一个 epoll 实例任务投递到线程池之后worker 执行的不应该是“读一次数据就结束”的短任务而应该是一个连接生命周期内完整的事件循环。这里我推荐每个 worker 维护自己的 epoll 实例而不是所有 worker 共用一个 epoll fd。共享 epoll fd 在多线程下虽然技术上可行但有一个很麻烦的问题多个线程同时对同一个 epoll fd 调用 epoll_wait内核并不能保证事件被哪个线程拿到。事件多的时候多个线程可能同时被唤醒然后各自竞争事件列表导致不必要的锁竞争和上下文切换。更糟糕的是如果连接对象和相应的事件回调分散在多个线程里线程安全问题会变得很难控制。每个 worker 一个 epoll 实例之后每个连接加入哪个 epoll就由 accept 线程在投递时决定。连接的一生都在同一个 worker 线程里处理读写、关闭、超时都不跨线程天然避开了很多数据竞争。这是“线程绑定连接”的核心价值。worker 内部的事件循环大概长这样void WorkerEntry(int fd) { int epfd epoll_create1(EPOLL_CLOEXEC); // 把连接 fd 加入当前 worker 的 epoll struct epoll_event ev; ev.events EPOLLIN | EPOLLET; ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev); struct epoll_event events[64]; while (!stop_flag) { int n epoll_wait(epfd, events, 64, -1); if (n 0) { if (errno EINTR) continue; break; } for (int i 0; i n; i) { int cur_fd events[i].data.fd; if (events[i].events (EPOLLERR | EPOLLHUP)) { epoll_ctl(epfd, EPOLL_CTL_DEL, cur_fd, nullptr); close(cur_fd); } else if (events[i].events EPOLLIN) { HandleRead(epfd, cur_fd); } else if (events[i].events EPOLLOUT) { HandleWrite(epfd, cur_fd); } } } }4.2 ET 模式下怎么把数据读干净边缘触发模式下epoll 只有在 fd 状态变化时才会通知一次。也就是说当 socket 缓冲区从无数据变成有数据你只会收到一次 EPOLLIN。如果一次 read 没有把数据读完剩下的数据不会再次触发事件必须靠 read 循环把它读完。正确做法是循环调用 read直到返回EAGAIN才停止。这个“读到 EAGAIN”的语义一定要理解透。简单说非阻塞 socket 在数据读完之后再次 read 会返回 -1errno 是EAGAIN或EWOULDBLOCK这时才能确认数据已经读清。void HandleRead(int epfd, int fd) { char buf[4096]; for (;;) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 把数据交给协议解析层 } else if (n 0) { // 对端关闭 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); close(fd); return; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; } if (errno EINTR) { continue; } // 其他错误关闭连接 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); close(fd); return; } } }这里有个性能细节read的缓冲区大小不能拍脑袋定。4KB 在大多数场景够用但如果业务包比较大可以按连接协商的缓冲区动态调整。一次性 read 到 64KB 能够减少系统调用次数但会增加栈占用通常把 buffer 放在堆上而不是每次循环重新分配。LT 模式则没有“必须读到 EAGAIN”的负担数据没读完会一直触发事件。代价是每次 epoll_wait 返回后你无法区分这次事件到底是新数据还是上次没读完的残留数据代码简单但事件通知次数会变多CPU 开销更大。在我的骨架里默认用 ET因为网络服务一旦上了规模减少无效唤醒对性能帮助很明显。4.3 停不下来eventfd 唤醒worker 事件循环里如果直接用epoll_wait(epfd, events, 64, -1)那么 stop_flag 的变化无法让 epoll_wait 立即返回。线程要等下一个连接事件或者超时才能发现停止信号这在优雅停机时是不可接受的。解决办法是给每个 worker 的 epoll 实例里注册一个 eventfd。eventfd 是 Linux 专门用来做事件通知的文件描述符可以把它加入 epoll。需要停止时往 eventfd 里写入一个值epoll_wait 就会立刻返回worker 线程检查到停止标志后就能退出循环。int wake_fd eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd wake_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, wake_fd, ev); // 停止线程池时 uint64_t one 1; write(wake_fd, one, sizeof(one));worker 循环里要判断events[i].data.fd wake_fd读到 eventfd 后先把数据读掉再检查 stop_flag。这个方案比给 epoll_wait 设置超时更优雅因为超时方案最长可能导致几百毫秒的退出延迟而 eventfd 是毫秒级唤醒。关闭顺序也要注意先删 epoll 事件再 close fd。如果顺序反了可能出现 fd 被关闭后epoll 内部还残留事件下次 epoll_wait 返回一个已经失效的 fd。这个问题的表现很隐蔽通常是偶发性崩溃而且只在连接活跃时触发。5. 故障排查从日志到连接数5.1 fd 被打满EMFILE 的连锁反应连接数一高第一个先爆的往往是文件描述符上限。用ulimit -n可以看到进程的软限制很多系统默认是 1024这个值对网络服务来说太小通常要调到 65535 甚至更高。但调高上限不代表不会再碰到 EMFILE。如果服务里还有配置文件、日志文件、静态资源文件占 fd实际可用的连接 fd 就会缩水。压测时连接数没到上限但日志文件轮转、临时文件打开等操作也可能瞬间吃光 fd。EMFILE 最麻烦的是前面说的 ET 事件丢失问题。我在一个模拟项目里复现过这个现象监听 fd 用 ET 模式压测客户端一次性发起大量连接服务端 fd 达到上限后 accept 返回 EMFILE此时内核认为 accept 事件已经通知过后续不再触发新的 EPOLLIN。结果就是客户端看到连接队列里还有大量连接排着队但服务端这边再也收不到 accept 事件服务看起来像“卡死”了。解决方案我在前面提过预留一个空闲 fd。每次 accept 失败且 errno 是 EMFILE 时先关闭空闲 fd然后调用 accept 把连接从队列里取出来再立即关闭最后重新打开一个空闲 fd。这样做可以保证 listen fd 上的事件被消费掉后续新连接到达时内核会继续触发事件。空闲 fd 可以是/dev/null或一个 pipe 的读端。另外对 listen fd 使用 LT 模式也能在一定程度避免这个问题因为 LT 模式下只要连接队列非空就会持续触发事件。LT 在事件丢失问题上更稳代价是 epoll_wait 会反复返回。如果对 accept 吞吐要求不是极端场景LT 是一个更省心的选择。5.2 唤醒丢失与惊群线程池最常见的隐性 bug 是任务投递后 worker 没有被唤醒或者多个 worker 同时被唤醒去抢同一个任务。先说唤醒丢失。如果把任务入队和notify_one()分两步写中间有异常或代码结构调整就可能出现“任务已经入队但没有 notify”的情况。正确做法是保证入队和通知在同一个临界区或同一个逻辑原子块内。比如Submit里先 lock_guard 入队再 notifynotify 时不需要持锁但必须保证 notify 发生在入队之后。再说惊群。如果一开始图简单让所有 worker 都notify_all()那么每个任务进来所有 worker 都会醒一次最后只有一个 worker 拿到任务其他 worker 空转。notify_one()只唤醒一个 worker能避免大部分惊群但条件变量在实现上仍可能出现多个线程同时醒来的情况。不过 worker 拿到锁之后会重新检查任务队列没有任务就会继续等待所以不会出错只是多一次上下文切换。还有一类惊群在 accept 层面如果多个线程同时阻塞在 accept 上新连接到达时内核会唤醒多个线程但只有一个能 accept 成功。这套骨架里我们只有一条 accept 线程天然没有 accept 惊群。如果以后要扩展成多 accept 线程建议考虑SO_REUSEPORT让内核在不同监听 socket 之间做负载均衡而不是靠多个线程抢同一个 listen fd。5.3 队列堆积和背压worker 处理不过来的一个信号是任务队列持续增长。如果只在投递端加日志可能看不出来最好在队列里加一个计数器每入队一个任务递增每取出一个任务递减定期上报。队列堆积时不能放任不管。连接任务堆积意味着客户端已经建立了 TCP 连接但服务端还没有开始处理它的请求。客户端通常会认为连接已经可用开始发送数据。这些数据会堆积在 socket 缓冲区里如果队列继续堆积最终 socket 缓冲区满了客户端写入就会阻塞TCP 窗口变成零。处理背压的方式有几种。最简单的是在 fault 时让 accept 线程短暂sleep给 worker 一点时间消费队列。更加可控的方式是给线程池加一个“最大待处理任务数”超过之后Submit返回失败由上层决定是拒绝连接还是暂时停止 accept。这个策略必须在项目初期就设计好否则后期改动会牵扯很多模块。6. 调优思路线程数、队列长度和连接上限6.1 压测时先观察哪几个指标骨架能跑通之后先别急着上线压一遍再说。我习惯观察四个指标每秒 accept 的连接数、worker 的 CPU 使用率、任务队列深度、单条连接从建立到完成一次请求的延迟。每秒 accept 连接数能反映出 accept 线程是否成为瓶颈。如果 accept 线程 CPU 接近 100%而 worker 还有空闲说明 accept 路径上做了多余的事情需要回退。worker 的 CPU 使用率要均衡理想状态是所有 worker 都在差不多的负载区间。如果某个 worker 长期偏高另外几个长期偏低要检查连接分配策略是不是有问题。队列深度是最直观的背压信号。正常情况下队列深度应该趋近于零或者在一个很小的范围内波动。如果队列深度持续上涨说明 worker 消费速度跟不上 accept 投递速度要么增加 worker要么降低 accept 速率。不过增加 worker 的前提是 CPU 还有余量如果所有核都已经跑满加线程只会增加切换成本。单条连接的延迟要分别看平均延迟和尾延迟。在高并发场景下平均延迟容易被乐观数据拉低真正影响用户体验的是 99 分位甚至 99.9 分位的延迟。尾延迟高往往和线程切换、任务队列等待、锁竞争有关这些都需要结合具体压测结果定位。6.2 经验参数线程数按核心数起步纯 CPU 密集业务用hardware_concurrency()有阻塞 IO 的业务可以乘 1.5 到 2但不要超过太多。连接分配用取模策略起步简单直观如果压测发现负载不均再改成最少连接调度。任务队列长度建议设为 worker 数量和单 worker 可承载连接数的乘积。比如 8 个 worker每个 worker 计划管理 1024 条连接那么队列上限可以设在 8192 左右。这个数值不是绝对的但要保证上限低于系统最大 fd 数量否则队列还没满fd 先爆了。epoll 事件数组大小用 64 起步比较合理太小会导致 epoll_wait 频繁返回太大则会浪费内存。压测时如果发现单次 epoll_wait 返回事件数经常接近数组上限再调大到 128 或 256。注意事件数组大不等于性能好核心是减少系统调用次数同时避免一次处理过多事件导致后续连接等待过久。6.3 连接上限和拒绝策略网络服务必须显式设置最大连接数上限。这个上限不是单指 fd 数量而是应用层允许同时存在的活跃连接数。超出上限时策略可以是直接 close也可以返回一个 503 类似的错误再关闭。直接 close 最省事但在协议层不规范客户端会看到连接被中断先下发一个明确错误再关闭对客户端更友好。这里值得提醒的是拒绝连接不能只是简单 close 就完事。要记录拒绝原因、当前连接数、队列深度等现场信息方便事后排查。很多时候服务被打满不是单一原因而是某条业务逻辑阻塞了 worker导致连接堆积最终触发保护逻辑。没有现场日志这类问题只能靠猜。我个人在实际操作中的体会是线程池接入阶段不要着急优化先把 accept、任务队列、worker 事件循环、优雅停止这四件事跑通。很多性能问题其实不是某一处代码慢而是模块之间没有配合好。比如任务队列无界导致内存爆炸比如 epoll ET 模式数据没读完导致事件丢失比如 eventfd 没接入导致停止时卡死。这些坑单独看都不难但组合在一起就会让你排查到怀疑人生。还有一个建议每接入一个模块就配一个最小可复现的压测脚本。脚本不需要很复杂能模拟并发建立连接、并发发送数据、随机断开连接就行。有了这个脚本改完代码跑一遍比做十次代码评审都有效。这个骨架到这一步已经具备最基本的并发能力后面再往里加协议解析、定时器、连接池时都会轻松很多。

相关新闻

用Terser优化E2E测试代码:Cypress和Playwright体积与CI时间大幅下降

用Terser优化E2E测试代码:Cypress和Playwright体积与CI时间大幅下降

测试代码这东西,平时不给它做优化,等到项目一大了,它自己就会跑出来找你算账。尤其是端到端(E2E)测试,用 Cypress 或 Playwright 写出来的一大堆用例,里面充斥着辅助函数、动态选择器、接口 Moc…

2026/10/17 15:17:53 阅读更多 →
HBase与Iceberg:从列式存储误解到数据湖表格式演进

HBase与Iceberg:从列式存储误解到数据湖表格式演进

前两天在技术社群里看到有人提问:HBase到底算不算列式存储?底下吵了几十楼,有人搬出“列式数据库鼻祖”的说法,也有人坚持HBase是NoSQL行存,跟分析型列存没半毛钱关系。说实话,这个问题值得认真回答&#x…

2026/10/17 15:17:53 阅读更多 →
多任务学习实战指南:从共享参数到损失平衡的完整落地路径

多任务学习实战指南:从共享参数到损失平衡的完整落地路径

我在真正动手做多任务学习之前,其实犹豫了很长时间。当时手里的线上模型数量已经多到快失控,三个场景、六个标签,每个都单独训练、单独上线,时间一长光是特征对齐和结果口径的校验就耗掉不少人力。促使我去认真研究Multi-Task Lea…

2026/10/17 15:17:53 阅读更多 →

最新新闻

Vim+GCC+库文件:打通Linux开发工具链的完整闭环

Vim+GCC+库文件:打通Linux开发工具链的完整闭环

我见过太多刚切到 Linux 的开发者,第一周就被劝退。倒不是 C 语言难,而是卡在了工作流上:在终端里不知道怎么快速改代码,写完程序不知道怎么编译,编译完了遇到各种链接报错又一头雾水。说白了,就是 Vim 操作…

2026/10/17 16:14:27 阅读更多 →
大漠插件7.2213 + Python:DmHelper注册调用与桌面自动化避坑指南

大漠插件7.2213 + Python:DmHelper注册调用与桌面自动化避坑指南

简介:面向大漠插件7.2213的Python注册示例包,内含作者自制的DmHelper脚本,用于演示如何通过Python语言调用大漠插件接口,完成注册、激活以及基本功能调用。大漠插件在界面自动化、软件测试、网页数据抓取等日常开发场景中应用广泛…

2026/10/17 16:14:27 阅读更多 →
5 分钟上手 North-Micro-Vision-Instruct-bf16:在 Apple Silicon 上本地跑通图像描述的完整指南

5 分钟上手 North-Micro-Vision-Instruct-bf16:在 Apple Silicon 上本地跑通图像描述的完整指南

【免费下载链接】North-Micro-Vision-Instruct-bf16 项目地址: https://ai.gitcode.com/hf_mirrors/mlx-community/North-Micro-Vision-Instruct-bf16 点击查看 免费下载 North-Micro-Vision-Instruct-bf16 是一个专为 Apple Silicon(M 系列芯片&#x…

2026/10/17 16:14:27 阅读更多 →
鸿蒙下Flutter测试框架反射装载适配与规模化分发

鸿蒙下Flutter测试框架反射装载适配与规模化分发

test_reflective_loader 这个名字,很多 Flutter 开发者可能只在跑测试时瞥见过一眼,但真正能说清它底层在干嘛的人不多。这次我们团队在把一套针对鸿蒙 HarmonyOS 的 Flutter 测试基建落地时,在这个组件上扎扎实实折腾了几周,踩了…

2026/10/17 16:14:27 阅读更多 →
XERJ零配置自动索引教程:一条命令让PDF、CSV、SQLite和日志全部可搜索

XERJ零配置自动索引教程:一条命令让PDF、CSV、SQLite和日志全部可搜索

XERJ零配置自动索引教程:一条命令让PDF、CSV、SQLite和日志全部可搜索 【免费下载链接】xerj XERJ is the new way for AI to search data. Its autoindex capability activates agents to know your data without the token waste of grep and sed. One command in…

2026/10/17 16:14:27 阅读更多 →
Qt飞机大战飞机类设计:QGraphicsScene、状态机与碰撞检测

Qt飞机大战飞机类设计:QGraphicsScene、状态机与碰撞检测

1. 飞机类挂在 QGraphics 场景里,是我试出来的最优解1.1 一开始我也用过 QWidget 硬拼飞机大战在 Qt 里算一个很经典的练手项目,但大多数教程会把重点放在"怎么把窗口搭出来""怎么加背景图",真到自己动手写飞机类的时候&…

2026/10/17 16:13:26 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/16 14:23:36 阅读更多 →