当前位置: 首页 > news >正文

Linux笔记---epoll用法及原理:从内核探究文件等待队列的本质-回调机制

1. epoll用法介绍

epoll 是 Linux 特有的 I/O 多路复用机制,主要用于高效处理大量并发连接,特别适合高并发网络编程场景。相比传统的 select 和 poll,epoll 具有更高的性能和扩展性。

epoll涉及到三个核心的系统调用:epoll_create、epoll_ctl、epoll_wait。

1.1 epoll_create()

epoll_create 是 Linux 系统中用于创建 epoll 实例的系统调用,该实例是一个内核数据结构,用于管理后续需要监控的文件描述符集合。

#include <sys/epoll.h>int epoll_create(int size);

参数:

  • size:在早期 Linux 版本(2.6.8 之前)中,size 表示预期监控的文件描述符数量上限,内核会根据此值预分配资源;但在现代 Linux 中,该参数已被忽略(仅需传入一个大于 0 的值即可),内核会动态调整资源。

返回值:

  • 成功时返回一个新的 epoll 文件描述符(epfd),用于后续的 epoll_ctl(添加 / 修改 / 删除监控对象)和 epoll_wait(等待事件)操作;
  • 失败时返回 -1,并设置 errno 表示错误原因。

1.2 epoll_ctl()

epoll_ctl 是 Linux 中 epoll 机制的核心控制函数,用于向 epoll 实例(由 epoll_create 或 epoll_create1 创建)添加、修改或删除需要监控的文件描述符(file descriptor)及其关联的事件。它是连接 epoll 实例与监控对象的关键接口。

#include <sys/epoll.h>int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数

  • epfdepoll 实例文件描述符(由 epoll_create 或 epoll_create1 返回),即要操作的目标 epoll 实例。
  • op操作类型,指定对 fd 执行的动作,只能是以下三者之一:
    • EPOLL_CTL_ADD:向 epoll 实例添加要监控的文件描述符 fd,并关联 event 结构体中定义的事件。
    • EPOLL_CTL_MOD:修改已添加到 epoll 实例中的 fd 所关联的事件(event 结构体需指定新的事件)。
    • EPOLL_CTL_DEL:从 epoll 实例中删除 fd(停止监控该文件描述符),此时 event 参数需设为 NULL
  • fd:要操作的文件描述符(如 socket、管道、标准输入等),即需要被 epoll 监控的对象。
  • struct epoll_event *event:指向 epoll_event 结构体的指针,用于描述 fd 要监控的事件及附加数据。结构体定义如下:
    struct epoll_event {uint32_t events;  // 要监控的事件(位掩码)epoll_data_t data;  // 用户数据(与事件关联的附加信息)
    };// data 是一个联合体,可存储多种类型的用户数据
    typedef union epoll_data {void    *ptr;  // 指针(可指向自定义数据结构)int      fd;   // 文件描述符(常用)uint32_t u32;  // 32位无符号整数uint64_t u64;  // 64位无符号整数
    } epoll_data_t;events 是位掩码,可通过按位或(|)组合多个事件,常用取值:EPOLLIN:表示 fd 可读(如 socket 收到数据、管道有数据可读)。
    EPOLLOUT:表示 fd 可写(如 socket 发送缓冲区有空闲空间)。
    EPOLLRDHUP:TCP 连接被对端关闭,或对端关闭了写操作(仅用于流式 socket)。
    EPOLLPRI:fd 有紧急数据可读(如带外数据)。
    EPOLLERR:fd 发生错误(无需主动设置,内核会自动触发)。
    EPOLLHUP:fd 被挂起(如管道的写端关闭,无需主动设置,内核会自动触发)。
    EPOLLET:设置为边缘触发(Edge Triggered)模式(默认是水平触发 Level Triggered)。
    EPOLLONESHOT:一次性监控,事件触发后自动从 epoll 实例中删除 fd,需重新添加才能再次监控。

返回值

  • 成功时返回 0;
  • 失败时返回 -1,并设置 errno 表示错误原因。
1.2.1 epoll 的两种触发模式

epoll 支持两种事件触发模式,决定了事件何时被通知给用户态:

  1. 水平触发(Level Triggered,LT)—— 默认模式
    触发条件只要 fd 满足事件条件(如缓冲区有数据可读),epoll_wait 就会持续返回该事件,直到条件不满足(如数据被读完)。
    特点:简单易用,即使一次没处理完数据,下次仍会触发,适合大多数场景。
  2. 边缘触发(Edge Triggered,ET)
    触发条件:仅当 fd 有新的事件满足时触发一次(如缓冲区从空变为有数据),之后即使条件仍满足(如数据未读完),也不会再次触发。
    特点:效率更高(减少不必要的通知),但要求必须一次性处理完所有数据(如循环读取直到 EAGAIN 错误),否则会遗漏事件。

1.3 epoll_wait()

epoll_wait 是 Linux 中 epoll 机制的核心等待函数,用于阻塞等待 epoll 实例(由 epoll_create/epoll_create1 创建)中监控的文件描述符(file descriptor)发生注册的事件(如可读、可写等)。它是 epoll 机制中获取事件并进行处理的关键接口。

#include <sys/epoll.h>int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数

  • epfd:epoll 实例的文件描述符(由 epoll_create/epoll_create1 返回),即要等待事件的目标 epoll 实例。
  • events:指向 struct epoll_event 数组的指针,用于存储内核返回的 “已就绪事件”。数组中的每个元素对应一个发生了事件的文件描述符及其事件信息
  • maxeventsevents 数组的最大容量(即最多能存储多少个事件),必须大于 0(否则会返回错误)。
  • timeout:超时时间(单位:毫秒),用于控制阻塞行为:
    • timeout = -1:无限期阻塞,直到有事件发生或被信号中断。
    • timeout = 0:立即返回,无论是否有事件发生(非阻塞模式)。
    • timeout > 0:最多阻塞 timeout 毫秒,若超时仍无事件则返回 0。

1.4 使用示例

用epoll实现一个简单的单进程并发TCP服务器(仅作示例),完整代码参考:https://gitee.com/da-guan-mu-lao-sheng/linux-c/tree/master/%E5%A4%9A%E8%B7%AF%E8%BD%AC%E6%8E%A5/Epoll

#pragma once
#include <sys/epoll.h>
#include <unordered_map>
#include "Common.hpp"
#include "Socket.hpp"
using namespace SocketModule;class EpollServer : public NoCopy
{
private:void ListenSocketHandler(){std::shared_ptr<TCPConnectSocket> connect_socket = _listen_socket->Accept();if (!connect_socket){ // 检查Accept是否成功LOG(LogLevel::ERROR) << "Accept failed";return;}int connect_sockfd = connect_socket->SockFd();_connect_socket[connect_sockfd] = connect_socket;epoll_event epev;epev.events = EPOLLIN;epev.data.fd = connect_sockfd;int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, connect_sockfd, &epev);if (n == -1){LOG(LogLevel::FATAL) << "epoll_ctl: 连接套接字注册失败! ";exit(EPCTL_ERROR);}}void ConnectSocketHandler(int fd){auto it = _connect_socket.find(fd);if (it == _connect_socket.end()){LOG(LogLevel::ERROR) << "Invalid socket fd: " << fd;return;}std::string message;int n = it->second->Receive(message);std::string client = it->second->Addr().Info();if (n > 0){// 正常收到消息std::cout << "Client[" << client << "] say# " << message << std::endl;}else if (n == 0){// 客户端断开连接LOG(LogLevel::INFO) << "Client[" << client << "]已断开连接...";// epoll_ctl只能删除合法的fd,所以需要先从epoll中删除,再关闭套接字if (epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr) == -1){LOG(LogLevel::ERROR) << "epoll_ctl: 套接字关闭失败! ";}_connect_socket.erase(it);}else{// 出错LOG(LogLevel::ERROR) << "Receive: 接收Client[" << client << "]的数据失败! ";if (epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr) == -1){LOG(LogLevel::ERROR) << "epoll_ctl: 套接字关闭失败! ";}_connect_socket.erase(it);}}void Dispatch(int n){for (int i = 0; i < n; i++){if (!(_events[i].events & EPOLLIN))continue;if (_events[i].data.fd == _listen_socket->SockFd()){// 监听套接字ListenSocketHandler();}else{// 连接套接字ConnectSocketHandler(_events[i].data.fd);}}}public:EpollServer(in_port_t port, size_t size): _isrunning(false), _listen_socket(std::make_shared<TCPListenSocket>(port)), _events(size){if (!_listen_socket || _listen_socket->SockFd() < 0){LOG(LogLevel::FATAL) << "PollServer: 初始化监听套接字失败! ";exit(EXIT_FAILURE);}// 创建epoll模型_epfd = epoll_create(size);if (_epfd == -1){LOG(LogLevel::FATAL) << "epoll_create: 创建epoll模型失败! ";exit(EPCREATE_ERROR);}// 将监听套接字注册到epoll模型int listen_sockfd = _listen_socket->SockFd();epoll_event epev;epev.events = EPOLLIN;epev.data.fd = listen_sockfd;int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, listen_sockfd, &epev);if (n == -1){LOG(LogLevel::FATAL) << "epoll_ctl: 监听套接字注册失败! ";exit(EPCTL_ERROR);}}void Run(){_isrunning = true;while (_isrunning){int n = epoll_wait(_epfd, _events.data(), _events.size(), 10000);if (n < 0){// 处理错误,EINTR是可恢复的if (errno == EINTR)continue;LOG(LogLevel::FATAL) << "poll error: " << strerror(errno);break;}else if (n == 0){// 超时LOG(LogLevel::DEBUG) << "poll timeout...";}else{// 有事件就绪LOG(LogLevel::INFO) << "有" << n << "个事件就绪, 即将处理...";Dispatch(n);}}_isrunning = false;}~EpollServer(){_isrunning = false;}private:bool _isrunning;std::shared_ptr<TCPListenSocket> _listen_socket;                            // 监听套接字std::unordered_map<int, std::shared_ptr<TCPConnectSocket>> _connect_socket; // 需要poll等待的连接套接字int _epfd;                                                                  // epoll模型文件描述符std::vector<epoll_event> _events;                                           // epoll事件缓冲区
};

2. epoll原理介绍

2.1 select/poll 的痛点

在 epoll 出现前,select 和 poll 是主流的 I/O 多路复用方案,但存在明显缺陷:

  1. 文件描述符(fd)数量限制:select 受限于 FD_SETSIZE(通常为 1024),无法监控大量 fd;
  2. 低效的轮询机制:每次调用 select/poll 都需要遍历所有监控的 fd 检查事件,时间复杂度为 O(n),fd 越多效率越低;
  3. 用户态与内核态的数据复制:每次调用需将整个 fd 集合从用户态复制到内核态,fd 越多,复制开销越大;
  4. 重复监控:若事件未处理完毕(如数据未读完),select/poll 会重复触发,导致不必要的处理。

2.2 epoll 的核心改进

epoll 针对上述问题设计了全新机制(事件驱动 + 高效数据结构),核心思路是:内核维护监控的 fd 集合,并主动通知用户态哪些 fd 发生了事件,避免轮询和重复复制

其核心组件包括:

  1. 红黑树(RB-Tree):内核用红黑树存储所有被监控的 fd 及其事件(由 epoll_ctl 管理),支持高效的添加、删除、修改操作(时间复杂度 O(log n));
  2. 就绪链表(Ready List):内核维护一个双向链表,仅存储发生了事件的 fd。epoll_wait 直接从该链表获取结果,无需遍历所有 fd(时间复杂度 O(1));
  3. 回调机制:当 fd 状态变化(如可读 / 可写)时,内核通过回调函数将其加入就绪链表,实现 “事件驱动” 通知。

2.3 epoll底层原理详解

2.3.1 epoll实例的本质

根据之前的介绍我们知道,epoll实例是以文件描述符的形式返回的,那么epoll与文件系统到底有什么关系呢?

epoll_create()系统调用会在内核中创建一个 epoll 实例(struct eventpoll),包含红黑树(存储监控的 fd)和就绪链表(存储活跃 fd)。

struct eventpoll {rwlock_t lock;                  // 1. 读写锁,保护结构体内部数据struct rw_semaphore sem;        // 2. 读写信号量,用于更复杂的同步wait_queue_head_t wq;           // 3. epoll_wait() 的等待队列wait_queue_head_t poll_wait;    // 4. 供 file->poll() 使用的等待队列struct list_head rdllist;       // 5. 就绪队列(存储发生事件的 fd 结构)struct rb_root rbr;             // 6. 红黑树根(存储所有被监控的 fd 结构)
};

在struct file当中存在如下两个与epoll有关的字段:

  • void *private_data:存储文件的私有数据(驱动 / 文件系统专用上下文),用于让驱动程序、文件系统或内核子系统存储与当前打开文件(struct file)关联的私有数据。例如:
    • 普通文件实例的private_data指向负责读取文件内容的驱动程序的私有数据;
    • socket实例的private_data指向其对应的struct socket实例;
    • epoll实例的private_data指向就指向其对应的struct eventpoll实例。
  • struct list_head f_ep_links:关联监控当前文件的所有 epoll 实例。即,如果有任何epoll实例正在监控当前文件,则该epoll实例会被关联到这个结构体中;当事件就绪时,这个文件实例会通过这个字段来通知所有相关的epoll实例。

2.3.2 红黑树与就绪队列

红黑树及就绪队列的行为:

  • 当某个文件描述符被加入到epoll实例当中时,就是在eventpoll的红黑树当中增加一个结点;
  • 当某个文件描述符的事件就绪时,回调方法被下层调用,该结点就被链入就绪队列当中;
  • 当某个文件描述符被从epoll中删除时,该结点本质上就是被从红黑树中删除。

这二者使用的的基本数据结构(即结点)相同,都是epitem,每一个结点都表示被 epoll 监控的一个文件描述符(fd)及其关联的事件信息

struct epitem {struct rb_node rbn;                  // 1. 红黑树节点,用于加入 eventpoll 的红黑树struct list_head rdllink;            // 2. 链表头,用于加入 eventpoll 的就绪队列struct epoll_filefd ffd;             // 3. 关联的文件描述符信息int nwait;                           // 4. 关联的活跃 poll 等待队列数量struct list_head pwqlist;            // 5. 存储 poll 等待队列的链表struct eventpoll *ep;                // 6. 指向所属的 epoll 实例(eventpoll)struct epoll_event event;            // 7. 用户注册的感兴趣的事件及附加数据atomic_t usecnt;                     // 8. 引用计数,防止结构体被意外释放struct list_head fllink;             // 9. 链表头,用于加入 struct file 的 f_ep_links 链表struct list_head txlink;             // 10. 链表头,用于加入事件传输列表unsigned int revents;                // 11. 实际发生的事件(供用户态获取)
};
  • struct rb_node rbn:这个字段是单独的红黑树结点不假,但是这个字段是epitem的第一个字段,所以其起始地址与整个epitem相同。将这个结点加入到红黑树当中,无异于将整个epitem加入到红黑树当中。
  • struct list_head rdllink:这个字段相信大家都有印象,我们之间在讨论进程控制块PCB时也见到过struct list_head类型的字段。同样地,我们将这个字段链接到就绪队列当中,本质上也就是将整个epitem链接到了就绪队列当中,只不过起始地址差了一个常量(rbn的大小)
    struct list_head {struct list_head *next, *prev;
    };

所以,epitem结点可以同时存在于红黑树与就绪队列当中。

2.3.3 回调机制

2.3.3.1 ep_poll_callback()

ep_poll_callbackepoll 回调机制的核心函数,当事件就绪时,该函数就会被调用,其主要逻辑是:将发生事件的 epitem 加入 epoll 实例的就绪队列,并唤醒等待的进程。

  1. 定位 epitem:通过 eppoll_entry->epi 找到对应的 epitem,再通过 epitem->ep 定位到所属的 epoll 实例(struct eventpoll)。
  2. 加锁保护:获取 eventpoll->lock 读写锁,防止多线程操作冲突。
  3. 判断事件类型:根据 fd 实际发生的事件(如可读、可写),结合用户注册的感兴趣事件(epitem->event.events),确定是否需要将 epitem 加入就绪队列。 例如:若用户注册了 EPOLLIN(可读),且 fd 确实有数据可读,则满足条件。
  4. 加入就绪队列:若事件匹配,将 epitem 通过 rdllink 加入 eventpoll->rdllist(就绪队列),确保 epoll_wait 能获取到该事件。
  5. 唤醒等待进程:若 epoll 实例的 wq 队列上有等待的进程(即 epoll_wait 正在阻塞),则唤醒这些进程,使其从 epoll_wait 中返回并处理事件。
  6. 解锁:释放 eventpoll->lock 锁。
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{int pwake = 0;unsigned long flags;struct epitem *epi = ep_item_from_wait(wait);struct eventpoll *ep = epi->ep;DNPRINTK(3, (KERN_INFO "[%p] eventpoll: poll_callback(%p) epi=%p ep=%p\n",current, epi->ffd.file, epi, ep));write_lock_irqsave(&ep->lock, flags);/** If the event mask does not contain any poll(2) event, we consider the* descriptor to be disabled. This condition is likely the effect of the* EPOLLONESHOT bit that disables the descriptor when an event is received,* until the next EPOLL_CTL_MOD will be issued.*/if (!(epi->event.events & ~EP_PRIVATE_BITS))goto is_disabled;/* If this file is already in the ready list we exit soon */if (ep_is_linked(&epi->rdllink))goto is_linked;list_add_tail(&epi->rdllink, &ep->rdllist);is_linked:/** Wake up ( if active ) both the eventpoll wait list and the ->poll()* wait list.*/if (waitqueue_active(&ep->wq))__wake_up_locked(&ep->wq, TASK_UNINTERRUPTIBLE |TASK_INTERRUPTIBLE);if (waitqueue_active(&ep->poll_wait))pwake++;is_disabled:write_unlock_irqrestore(&ep->lock, flags);/* We have to call this outside the lock */if (pwake)ep_poll_safewake(&psw, &ep->poll_wait);return 1;
}
2.3.3.2 回调函数的注册

当通过 epoll_ctl(EPOLL_CTL_ADD) 向 epoll 实例添加一个 fd 时,内核会完成以下操作,本质是为该 fd 注册一个 epoll 专用的回调函数:

  1. 创建 epitem:生成一个 struct epitem 结构体,关联该 fd(通过 epitem->ffd 指向 struct file)和 epoll 实例(epitem->ep 指向 struct eventpoll)。
    sys_epoll_ctl(int epfd, int op, int fd, struct epoll_event __user *event)
    {int error;struct file *file, *tfile;struct eventpoll *ep;struct epitem *epi;struct epoll_event epds;// ...// 将epitem关联到epoll实例ep = file->private_data;down_write(&ep->sem);epi = ep_find(ep, tfile, fd);error = -EINVAL;// 根据op跳转对应的操作switch (op) {case EPOLL_CTL_ADD:if (!epi) {epds.events |= POLLERR | POLLHUP;error = ep_insert(ep, &epds, tfile, fd);} elseerror = -EEXIST;break;case EPOLL_CTL_DEL:if (epi)error = ep_remove(ep, epi);elseerror = -ENOENT;break;case EPOLL_CTL_MOD:if (epi) {epds.events |= POLLERR | POLLHUP;error = ep_modify(ep, epi, &epds);} elseerror = -ENOENT;break;}// ...
    }
  2. 注册等待队列项:内核会为该 fd 对应的文件(struct file)注册一个 “等待队列项”(struct eppoll_entry),其中包含 epoll 专用的回调函数 ep_poll_callback。
  3. 关联管理:将 eppoll_entry 通过 llink 加入 epitem->pwqlist 链表(便于后续管理),并通过 nwait 计数活跃的等待队列项数量。

2、3点看不明白没关系,我们结合代码从ep_insert函数开始,逐步跳转:

static int ep_insert(struct eventpoll *ep, struct epoll_event *event,struct file *tfile, int fd)
{// ...init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);// ...
}static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,poll_table *pt)
{struct epitem *epi = ep_item_from_epqueue(pt);struct eppoll_entry *pwq;if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, SLAB_KERNEL))) {// !!!在此处将ep_poll_callback注册到eppoll_entry结构的wait字段!!!init_waitqueue_func_entry(&pwq->wait, ep_poll_callback); pwq->whead = whead;// !!!在eppoll_entry当中存放epitem的指针,因此回调函数能在O(1)时间内将就绪结点链接到就绪队列!!!pwq->base = epi;// !!!将携带回调方法的等待队列项注册到对应文件的等待队列!!!add_wait_queue(whead, &pwq->wait);// !!!通过llink将eppoll_entry加入到epitem的pwqlist!!!list_add_tail(&pwq->llink, &epi->pwqlist);epi->nwait++;} else {epi->nwait = -1;}
}struct eppoll_entry {struct list_head llink;             // 1. 用于链接到 struct epitem 的链表头void *base;                         // 2. 指向所属的 struct epitem 结构体wait_queue_t wait;                  // 3. 等待队列项,将被加入目标文件的等待队列wait_queue_head_t *whead;           // 4. 指向"wait"所加入的目标文件的等待队列头
};typedef struct __wait_queue wait_queue_t;
struct __wait_queue {unsigned int flags;
#define WQ_FLAG_EXCLUSIVE	0x01void *private;wait_queue_func_t func;             // 回调函数struct list_head task_list;
};

也就是说,回调方法最终是存放在eppoll_entry的wait字段的func字段,然后wait字段被链入到某资源(也即文件)的等待队列当中。

也即,回调方法ep_poll_callback 最终被注册到被监控文件(或资源)的等待队列头(wait_queue_head_t) 中,作为等待队列项(wait_queue_t)的回调函数存在。

注意wait_queue_t的private字段,这个字段很显然就对应struct file中的private。

想必不少读者看到这里头都快炸了,没关系,结合下面这张图来看:

2.3.3.3 文件等待队列的本质

我们之前说,进程在等待某个资源时,其PCB会被链接到文件的等待队列当中,但实际上这个说法并不准确。

现在我们已经知道文件等待队列实际上就是wait_queue_t类型的链表,观察其中的private字段,我们很容易猜到,对于epoll实体来说,这个字段的指向就是struct eventpoll。

那么,对于socket来说是struct socket,对于进程来说,自然就是PCB。

所以从本质上来说,所有的进程等待资源的方式都是将自己的回调方法注册到文件的等待队列当中,在资源就绪时再依此调用这些回调方法

等待队列的工作机制:“注册回调→阻塞→资源就绪→执行回调→唤醒”:

  1. 注册等待:当进程通过系统调用(如 read、epoll_wait 等)等待某个资源(如文件数据可读)时,内核会创建一个 wait_queue_t(等待队列项),其中包含:
    • 回调函数(func):资源就绪时需要执行的逻辑;
    • 关联的进程(task_struct *):需要被唤醒的进程。 然后将这个 wait_queue_t 插入到资源的等待队列头(wait_queue_head_t)中,并将进程状态设为阻塞(如 TASK_INTERRUPTIBLE),放弃 CPU。
  2. 资源就绪时的处理:当资源状态变化(如文件有数据可读),内核会遍历该资源的等待队列头,对每个 wait_queue_t 执行以下操作:
    • 调用其回调函数(func)
    • 回调函数的逻辑通常包括:将进程状态从阻塞改为就绪(TASK_RUNNING),并将进程加入 CPU 就绪队列(等待调度)。

等待队列的核心逻辑是:等待者注册回调并阻塞→资源就绪时遍历队列执行回调→回调负责唤醒进程(或其他处理,如 epoll 的事件入队)。

这种机制让内核无需轮询资源状态,而是通过 “事件驱动” 高效处理阻塞与唤醒,是 Linux 内核 I/O 模型的基础。

http://www.dtcms.com/a/390329.html

相关文章:

  • Python快速入门专业版(三十三):函数参数陷阱:默认参数的“可变对象”问题(避坑指南)
  • Spring Security 框架 实践小项目(实现不同用户登录显示不同菜单以及每个菜单不同权限)
  • 开发避坑指南(49):Java Stream 对List中的字符串字段求和
  • 网络编程day02-组播,广播
  • 前端左侧菜单列表怎么写
  • LLM大模型和文心一言、豆包、deepseek对比
  • stm32h743iit6 配置 FMC 的时钟源
  • 中小企业数字化转型:从工具升级到思维转变
  • 数据传输中的三大难题,ETL 平台是如何解决的?
  • DAY16 字节流、字符流、IO资源的处理、Properties、ResourceBundle
  • 电气工程师面试题及答案
  • Halcon一维码与二维码识别技术解析
  • 【数据库系统Trip 第1站】总概
  • 关于 Python 编程语言常见问题及技术要点的说明
  • Mysql常用函数积累
  • AntV可视化(MCP 1.8)避坑指南
  • 学习日报|线程池 OOM
  • C# Progress
  • 【LeetCode 每日一题】3495. 使数组元素都变为零的最少操作次数
  • Part01、02 基础知识与编程环境、C++ 程序设计
  • C++聊天系统从零到一:brpc RPC框架篇
  • Java编程思想 Thinking in Java 学习笔记——第2章 一切都是对象
  • AssemblyScript 入门教程(2)AssemblyScript的技术解析与实践指南
  • 深入理解Java数据结构
  • 【试题】网络安全管理员考试题库
  • 第一章 信息化发展
  • 第六章:实用调试技巧
  • 人工智能通识与实践 - 智能语音技术
  • CSP-S 提高组初赛复习大纲
  • 卷积神经网络CNN-part7-批量规范化BatchNorm