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

多路转接select

上一篇:非阻塞 IOhttps://blog.csdn.net/Small_entreprene/article/details/148975395?fromshare=blogdetail&sharetype=blogdetail&sharerId=148975395&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link

select 核心定位

相关概念介绍

根据之前简单谈过的五种 IO 模型,select 是用来多路转接的!select 这个多路转接的接口是只负责 IO (等 + 拷贝)中的等的!只不过可以一次等待多个 fd !--- 第一层认识。

一旦等的多个 fd 有任意一个或者多个 fd 的时间就绪了,那么 select 会用一定的方式通知上层,告诉调用方,哪些 fd 已经可以进行 IO 了!--- 第二层认识。

结论:select 是通过等待多个 fd 的一种就绪时间通知机制!

事件???

  • 什么叫做文件描述符可以读了?就是底层有数据!只要底层有数据,我们就将底层有数据称为读事件就绪!
  • 什么叫做文件描述符可以写了?就是底层有空间!只要底层有空间,我们就将底层有空间称为写事件就绪!

最开始的 fd 的接受和发送缓冲区都是空的,也就是对于 fd,一般默认,读事件不就绪,写事件就绪

认识 select 函数

select函数是Linux系统编程中用于实现I/O多路复用的重要函数,它允许程序同时监视多个文件描述符,当其中任何一个文件描述符就绪(可读、可写或有异常条件待处理)时,select函数就会返回,程序可以针对就绪的文件描述符进行相应的I/O操作,从而实现高效地处理多个并发的I/O事件,常用于网络编程、多线程编程等场景。

函数原型

#include <sys/select.h>int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

首尾参数认识

  • nfds:指定要监视的文件描述符的范围,即最大的文件描述符加1。这个参数决定了select函数需要检查的文件描述符的最大范围。(我所等待的多个文件描述符其中的数字最大值 + 1)(maxfd + 1)(将来要检测3,4,5,6,7,10 ---- 那么该 nfds 需要设置为11,而不是6!!!)(主要是因为 select 的底层是循环的,循环正常都是左闭右开!)

  • timeout:指向struct timeval类型的指针,用于指定select函数的超时时间。如果在指定的时间内没有任何文件描述符就绪,select函数将返回,表示等待多个文件描述符超时。如果timeoutNULLselect函数将阻塞,直到有文件描述符就绪。(虽然 select 应用于多路复用,但是也还有阻塞和非阻塞这样的模式)如果将 timeout 的时间设置为0,那么 select 会检测我们要等待的多个文件描述符,在检测一轮之后,没有一个就绪,就不等特定时长,也不会一直等,而是会立即返回!这就是典型的非阻塞!!!

注意:timeout 还是一个输入输出型参数,如果今天要等的文件描述符:

  • 5秒以内如果没有文件描述符就绪,select 返回的时候,struct timeval 的值改成{0, 0};  
  • 如果在五秒内的第二秒有一个文件描述符就绪,select 返回,struct timeval 的值设置成剩余时间{3, 0}; 

所以这个结构体输入的时候代表设置的超时时长,返回值就是剩余时间!

所以 timeout 这个等待时间会为我们提供三种等待方式:

  1. 阻塞等待
  2. 非阻塞等待
  3. timeout 时间内,阻塞等待,超时就非阻塞返回

返回值(三种情况)

  • > 0:表示有文件描述符就绪,返回值为就绪的文件描述符数量。

  • 0:表示在超时时间内没有任何文件描述符就绪。如果 timeout 设置为 NULL,就不会有这种情况(要么大于0就绪,小于0出错)!

  • -1:表示出错,可能的原因包括无效的文件描述符、超时时间设置错误等。

struct timeval

对于最后一个参数的类型 --- struct timeval !

struct timeval 是一个在多种编程场景中广泛使用的结构体,特别是在与时间相关的操作中,比如网络编程中的超时设置、时间戳记录等。它定义在 <sys/time.h> 头文件中,用于表示时间间隔或时间点。

struct timeval {long tv_sec;  // 秒long tv_usec; // 微秒
};
  • tv_sec:表示秒数,是一个长整型(long)。

  • tv_usec:表示微秒数,也是一个长整型(long)。注意,微秒的范围是 0 到 999999。

struct timeval 主要用于以下几种场景:

  1. 时间间隔表示:在需要表示一个时间间隔(如超时时间)时,struct timeval 是一个非常方便的结构体。例如,在 select 函数中,timeout 参数就是一个指向 struct timeval 的指针,用于指定超时时间。

  2. 时间戳记录:在需要记录时间点时,struct timeval 也可以用来表示从某个特定时刻(如 1970 年 1 月 1 日 00:00:00 UTC)开始的秒数和微秒数。例如,gettimeofday 函数可以用来获取当前时间的时间戳。

中间三个参数的说明

我们上面将最好理解的收尾参数介绍了,接下来是时候该介绍中间的三个参数了!

未来我们在进行 IO 的时候,多路转接的核心定位是负责等的,会等待多个 fd 上面的就绪事件,因为我们主要做的不就是在等 fd 嘛!等 fd 的就绪事件,要么读事件就绪,要么写事件就绪,也就是 IO 事件就绪,所以 select 将读事件就绪和写事件就绪分开了!

  • readfds(只关心读事件):指向fd_set类型的指针,用于指定需要监视可读性的文件描述符集合。如果某个文件描述符对应的位被设置为1,表示该文件描述符需要被监视是否可读。

  • writefds(只关心写事件):指向fd_set类型的指针,用于指定需要监视可写性的文件描述符集合。如果某个文件描述符对应的位被设置为1,表示该文件描述符需要被监视是否可写。

  • exceptfds(异常事件关心):指向fd_set类型的指针,用于指定需要监视异常条件的文件描述符集合。如果某个文件描述符对应的位被设置为1,表示该文件描述符需要被监视是否有异常条件发生。

这三个参数在传参方面上是非常类似的,所以我们现在只讲一个,进而推广!

select 不是可以一次检测多个文件描述符吗?为什么没有检测见到个文件描述符吗?正常情况下不是下面这样的吗?

#include <sys/select.h>int select(int nfds, int fd1, int fd2, ... , struct timeval *timeout);

select 并没有这样做,不过我们可以看到有一个 fd_set ,这又是什么鬼呢?

fd_set

很明显,fd_set 是一个文件描述符集合,是内核提供给用户的数据结构,一次可以允许向 fd 里面添加多个 fd !

文件描述符本质是一个数组下标!那么位图结构就可以很好的总计出来有哪些文件描述符了,所以理所应当的,这个数据结构就是 --- 位图!

其实 fd_set* readfds 是一个输入输出型参数,readfds 是指向一个特定位图的指针,举个例子:

比特位的位置,代表 fd 的编号是多少。

  • 输入的时候:1000 1111(文件描述符0,1,2,3,7)输入的时候,用户告诉内核,你要帮我关心哪些 fd 上的那些读事件!
  • 输出的时候:0000 0000(代表文件描述符0,1,2,3,7没有一个就绪的)0000 0010(代表文件描述符0,1,2,3,7,其中1号文件描述符上的读事件就绪了)返回的时候内核告诉用户你让我关心的哪些 fd 上面的读事件已经就绪了!返回的位图的比特位的位置,依旧表示 fd 的编号,但是比特位的内容表示的是是否就绪!

细节:

  • 细节1:位图是输入输出的,所以,将来这个位图一定会被频繁变更!
  • 细节2:位图有多少个比特位,就决定了 select 最多能关心多少个 fd !
  • 细节3:fd_set 是一个系统提供的数据类型(struct),也就是说明 fd_set 大小固定!这就意味着 select 能够同时等待的 fd 是有上限的!
#include <iostream>
#include <sys/select.h>int main()
{fd_set fset;std::cout << sizeof(fd_set) * 8 << std::endl; // fd_set有多少个bit位return 0;
}

我们执行程序发现结果是:

1024

 也就是说 select 能够同时等到的 fd 的个数的最大值是1024个!!!

  • 细节4:readfds:如果将 fd 添加到 readfds 指向的集合中,表示该 fd 只要求内核帮其关心读事件!

如果我们想既关心读事件,又要关心写事件呢?

我们将要关心的 fd 既添加到 readfds,又要添加到 writefds 当中! 

如果我们想先关心读事件,再要关心写事件呢?

我们将要关心的 fd 先添加到 readfds 中,等数据读取完毕了,再要添加到 writefds 当中!  

如果我们想关心读事件,关心写事件,还关心异常事件呢?

我们将要关心的 fd 既添加到 readfds,又要添加到 writefds 当中!,还要添加到 exceptfds 当中!


虽然 fd_set 在底层本质上是一个位集合(bit array),但直接使用位操作来添加或移除文件描述符(FD)是不推荐的,因为这可能会导致错误或不兼容的行为。为了方便和安全地操作 fd_set,POSIX 标准提供了一组宏来管理 fd_set 中的文件描述符。这些宏包括:(底层就是位运算)

  1. FD_ZERO(fd_set *fdset):清空 fd_set,将所有位设置为 0。

  2. FD_SET(int fd, fd_set *fdset):将指定的文件描述符 fd 添加到 fd_set 中。

  3. FD_CLR(int fd, fd_set *fdset):从 fd_set 中移除指定的文件描述符 fd

  4. FD_ISSET(int fd, const fd_set *fdset):检查指定的文件描述符 fd 是否在 fd_set 中。


实现 select TCP 的 Echo Server

我们目的还是去看select如何设计的,该如何去编写,由于select比较复杂,我们在编写select的时候,只处理读取!因为将写带上,会更复杂,等后面epoll的时候,我们再将读和写统一处理,多路转接,只要将一种处理了,其他的也就会了!

需要让客户端发一个消息,然后服务端将信息打印出来就可以!!!(直接使用telnet进行测试)

SelectServer.hpp

#pragma once#include <iostream>
#include <memory>
#include <unistd.h>
#include "Socket.hpp"
#include "Log.hpp"using namespace SocketModule;
using namespace LogModule;class SelectServer
{const static int size = sizeof(fd_set) * 8; // 最大事件数const static int defaultfd = -1;public:SelectServer(int port) : _listensock(std::make_unique<TcpSocket>()), _isrunning(false){_listensock->BuildTcpSocketMethod(port);for (int i = 0; i < size; ++i){_fd_array[i] = defaultfd;}_fd_array[0] = _listensock->Fd(); // 将 listensockfd 添加到 _fd_array 中}~SelectServer(){}void Start(){_isrunning = true;while (_isrunning){// auto res = _listensock->Accept();// 我们在 select 这里,可以进行 accept 吗?// 因为 listensockfd 也是一个 fd ,进程怎么知道 listenfd 上面有新连接到来了呢?// accept在没连接的时候可是阻塞的,如果这么写了,不久造成了阻塞 IO 了!并不是多路转接!// 服务器在刚启动的时候,默认只有一个 fd ,accept本质就是一个阻塞 IO !!!// accept 本身就是一种 IO ,只不过他不是用来传输对应的用户数据的。只是用来负责获取连接的!// accept 只关心的是 listensockfd 的读事件(获取连接),对于 listen 套接字来说,不应该让 accept 去获取连接,因为连接不一定会来!// IO 当中的大部分时间在等/阻塞,所以我们首先做的是将 listensockfd 的读事件添加到 select 函数中,让 select 帮我关心读事件就绪!// 也就是 --- 新连接到来 == 读事件就绪!// 将 listensockfd 添加到 select 内部,让 OS 帮我关心 listensockfd 的读事件就绪!// 然后在 select 返回的时候,如果有新连接到来,就调用 accept 进行处理!// 处理完毕后,再次调用 select 进行下一次循环!// 这样就可以实现多路转接!// 我们将下面四条写在了while循环中,为什么这么写?//////fd_set rfds;    // 读事件集合FD_ZERO(&rfds); // 初始化读事件集合// FD_SET(_listensock->Fd(), &rfds); // 将 listensockfd 添加到读事件集合中int maxfd = defaultfd; // 最大 fdfor (int i = 0; i < size; ++i){if (_fd_array[i] == defaultfd){continue;}FD_SET(_fd_array[i], &rfds);           // 将 _fd_array 中的 fd 添加到读事件集合中maxfd = std::max(maxfd, _fd_array[i]); // 最大 fd}PrintFd(); // 打印 _fd_array[]// 此时有没有设置到内核中?是没有的!!!--- 因为 fd_set 定义的 rfds 是在用户栈上的,只是将位图设置了,将来需要交给 select 来设置的!// struct timeval timeout = {2, 0}; // 设置超时时间为 2 秒//////// 可是如果这几行代码放在 while 循环中,select 返回之后,还怎么知道哪些 fd 需要被添加到 rfds 中,让 select 关心呢?// 所以: select 要进行完整的设计,需要借助一个辅助数组!!!用来存放服务器历史获取过的所有的 fd !// 最大fd,一定是变化的// 每次 select 之前,都要重新设置 fd_set ! 因为 select 内部会修改 fd_set ! --- 使用辅助数组// 调用 select 函数,等待事件就绪int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr); // 调用 select 函数switch (n){case -1:LOG(LogLevel::ERROR) << "select error";break;case 0:LOG(LogLevel::INFO) << "time out ...";break;default:// 有事件就绪了: 不仅仅是新连接到来了,还有读事件就绪了!!!(未来还可以有写事件就绪)LOG(LogLevel::DEBUG) << "有事件就绪了 ... 事件个数 --- n: " << n;sleep(1);// 处理事件Dispatcher(rfds); // 处理就绪的事件!!!break;}}_isrunning = false;}void Stop(){_isrunning = false;}void PrintFd(){LOG(LogLevel::DEBUG) << ">>>";LOG(LogLevel::DEBUG) << "---------------------------------------------";LOG(LogLevel::DEBUG) << "_fd_array[] = ";for (int i = 0; i < size; ++i){if (_fd_array[i] == defaultfd){continue;}LOG(LogLevel::DEBUG) << "_fd_array[" << i << "] = " << _fd_array[i];}LOG(LogLevel::DEBUG) << "---------------------------------------------";LOG(LogLevel::DEBUG) << "<<<";}private:// 连接管理器void Accepter() // 新连接到来处理{InetAddr client;int sockfd = _listensock->Accept(&client); // 这里的 Accept 就不会阻塞了!!!因为 listen 套接字已经就绪了!这就是把等过程和拷贝的过程分离了!if (sockfd >= 0){// 获取新连接成功LOG(LogLevel::DEBUG) << "get a new link, sockfd: " << sockfd << ", client is: " << client.StringAddr();// 获取新连接到来成功,然后呢?可以直接进行 read/recv() 操作吗?// 可不敢!!!我们获得新连接,下一步要做的是:将新的 sockfd 托管给 select !!!// 如何托管? ----   将新连接的 sockfd 添加到 _fd_array 中,然后 while 循环中,将再次调用 select !!!int pos = 0;for (; pos < size; pos++){if (_fd_array[pos] == defaultfd){break;}}if (pos == size){LOG(LogLevel::ERROR) << "select server is full!";close(sockfd); // 关闭新连接}else{_fd_array[pos] = sockfd; // 将新连接的 sockfd 添加到 _fd_array 中}}}// IO 处理器void Recver(int sockfd, int pos) // 普通fd收到数据的读事件处理{// 处理 sockfd 读事件// 我们在这里读取的时候,就不会阻塞了 --- 因为 select 已经完成等操作了!char buf[1024];ssize_t n = recv(sockfd, buf, sizeof(buf - 1), 0);// recv 读的时候会有BUG!因为无法保证能够收到一个完整的请求!--- TCP 是流式协议!// 我们目前先不做处理,等到 epoll 的时候,再做处理!if (n > 0){buf[n] = 0;LOG(LogLevel::DEBUG) << "Client say#" << buf;}else if (n == 0){// 客户端关闭连接LOG(LogLevel::DEBUG) << "Client close the link, sockfd: " << sockfd;close(sockfd);              // 关闭连接_fd_array[pos] = defaultfd; // 将 sockfd 从 _fd_array 中移除}else{// 读错误LOG(LogLevel::ERROR) << "recv error, sockfd: " << sockfd;close(sockfd);              // 关闭连接_fd_array[pos] = defaultfd; // 将 sockfd 从 _fd_array 中移除}}// 事件派发器void Dispatcher(fd_set &rfds /*, fd_set &wfds, fd_set &efds*/){// 就不仅仅是处理新连接到来,还可以处理读事件就绪!// 只要指定的文件描述符,在 rfds 中,就证明该 fd 就绪了?for (int i = 0; i < size; ++i){if (_fd_array[i] == defaultfd){continue;}// fd 合法,并不一定就绪if (FD_ISSET(_fd_array[i], &rfds)) // 判断一个文件描述符是否在 rfds 中,在就证明该 fd 就绪了{// listensockfd 新连接到来,也是读事件就绪!// sockfd 数据到来,也是读事件就绪!// 怎么区分?if (_fd_array[i] == _listensock->Fd()){// 新连接到来Accepter();}else{// sockfd 数据到来// 处理 sockfd 读事件Recver(_fd_array[i], i);}}// if (FD_ISSET(_fd_array[i], &wfds))// {// }}}private:std::unique_ptr<Socket> _listensock;bool _isrunning;int _fd_array[size]; // 辅助数组,用来存放历史获取过的所有的 fd!
};

Main.cc

#include "SelectServer.hpp"int main(int argc, char *argv[])
{if (argc != 2){std::cout << "Usage: " << argv[0] << " port" << std::endl;exit(USAGE_ERR);}Enable_Console_Log_Strategy();uint16_t port = std::stoi(argv[1]);std::unique_ptr<SelectServer> svr = std::make_unique<SelectServer>(port);svr->Start();return 0;
}

代码中的问题:

问题1:我们在 select 这里,可以进行 accept 吗?

我们不能让 accept 直接去获取!

在使用 `select` 函数进行 I/O 多路复用时,我们可以在 `select` 中处理 `accept` 操作。虽然 `accept` 本身是一个阻塞 I/O 操作,但在 `select` 的上下文中,我们可以通过将监听套接字(`listensockfd`)的读事件添加到 `select` 函数中来避免阻塞。当 `select` 检测到 `listensockfd` 上有读事件就绪时,说明有新的连接到达,这时调用 `accept` 就不会阻塞,从而实现非阻塞的连接接受。因此,虽然 `accept` 本身是一个阻塞 I/O 操作,但在 `select` 的帮助下,我们可以有效地管理多个套接字,包括监听套接字和已连接套接字,从而实现高效的 I/O 多路复用。

结论:新连接到来 == 读事件就绪!

问题2:获取新连接到来成功,然后呢?可以直接进行 read/recv() 操作吗?

未来还有写,我们先处理读!我们可不敢直接在这读!因为别人和我连接建立好了,并不是对方就一定会基于这个连接来给我立马发送数据的!可以建立好连接,但是不立马发送数据的!!!

所以新连接到来了,但是listensockfd 创建的 fd 是否就绪,我们不清楚!不敢直接调用read!

只有 select 最清楚,未来 fd 上是否有事件就绪!

我们获得新连接,下一步要做的是:将新的 sockfd 托管给 select !!!

问题3:selet 一次可以管理多个文件描述符,管理的文件描述符是如何多起来的?

是因为我们未来一旦是 listen 套接字就绪,就可以从 listen 套接字中,不断获取新的文件描述符!不断添加到 select 中! 所以才会引出 select 内部管理的文件描述符增多!!!

问题4:由于前面几行代码放在 while 循环中,select 返回之后,还怎么知道哪些 fd 需要被添加到 rfds 中,让 select 关心呢?

// 我们将下面四条写在了while循环中,为什么这么写?//
fd_set rfds;                      // 读事件集合
FD_ZERO(&rfds);                   // 初始化读事件集合
FD_SET(_listensock->Fd(), &rfds); // 将 listensockfd 添加到读事件集合中
// 此时有没有设置到内核中?是没有的!!!--- 因为 fd_set 定义的 rfds 是在用户栈上的,只是将位图设置了,将来需要交给 select 来设置的!struct timeval timeout = {2, 0}; // 设置超时时间为 2 秒//

我们需要 select 要进行完整的设计,需要借助一个辅助数组!用来存放服务器历史获取过的所有的 fd ! 

重新进入while循环的时候,我们根据辅助数组,重新设置 readfds 指向的数据结构,就可以不断频繁的要求 select 帮我关心这些文件描述符了!

使用数组够用了!

const static int size = sizeof(fd_set) * 8; // 最大事件数
int fd_array[size]; // 辅助数组,用来存放历史获取过的所有的 fd !

问题5:针对问题2,我们该如何托管?也就是如何把对应的 sockfd 交给 select ?

将新的 fd 放入到辅助数组中就可以了!!!这样,下一次 while 循环的时候,就会将新的 fd 交给 select 了!

这时候,处理事件就不仅仅是考虑新连接到来了,还要考虑读事件就绪了!


  • 所以,select 就负责来做事件就绪检测!
  • 一旦事件就绪了,就使用事件派发器将就绪的事件派发到不同的模块当中!

我们多路转接效率会比较高,因为 select 用一个系统调用,使用单进程就可以一次等待多个文件描述符了,这就是典型的赵六,可以将文件描述符等待事件聚合在一起,将来文件描述符增多,每一个文件描述符就绪的概率就会增加!

多路转接 select 的优缺点

特点

可监控文件描述符个数有限制但可调整:可监控的文件描述符个数取决于 sizeof(fd_set) 的值。在服务器上,当 sizeof(fd_set)=512 时,每 bit 表示一个文件描述符,支持的最大文件描述符是 512×8=4096。不过 fd_set 的大小是可以调整的,只是可能涉及重新编译内核等操作。

需要额外数据结构配合使用:将 fd 加入 select 监控集的同时,还要再使用一个数据结构 array 保存放到 select 监控集中的 fd。其作用一是用于 select 返回后,array 作为源数据和 fd_set 进行 FD_ISSET 判断;二是 select 返回后会把以前加入的但并无事件发生的 fd 清空,所以每次开始 select 前都要重新从 array 取得 fd 逐一加入(FD_ZERO 最先),并且在扫描 array 的过程中取得 fd 最大值 maxfd,用于 select 的第一个参数。

缺点

使用不便:每次调用 select,都需要手动设置 fd 集合,从接口使用角度来说不太方便。

开销较大

  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,当 fd 数量较多时,这个开销会比较大。(但其实是位图结构,开销其实也不会大到哪去)(成本主要是如果 fd 多了,而且就绪的多了,从用户态拷贝到内核态的成本就变高了)---- 其实也不算是缺点,算是特点也行!

  • 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,当 fd 数量较多时,这个开销也很大。

支持的文件描述符数量有限:select 支持的文件描述符数量相对较小。只能处理一些中小型应用,大型应用搞不定!!!

可是,单进程下,一个进程能打开的文件 fd 的个数,也是有上限的啊!!!(fd 本质就是数组下标)--- 一般的文件!( Linux 内核支持文件描述符表做动态扩展!)

但是没有影响到 select ,这是 select 自己的问题,要清楚两个的概念,错的难道是要怪单进程打开文件有限吗!


正是因为 select 有上面这些缺点,所以我们为了解决这些问题,才需要有另一种多路转接技术 --- 在内核当中,我们称为 --- poll!!!

关于 poll ,我们下一篇介绍!

http://www.xdnf.cn/news/14734.html

相关文章:

  • Linux云计算基础篇(2)
  • SpringCloud系列(42)--搭建SpringCloud Config分布式配置总控中心(服务端)
  • Deepoc 大模型在无人机行业应用效果的方法
  • java JNDI高版本绕过 工具介绍 自动化bypass
  • 信息安全工程师考试架构相关说明
  • Nordic空中升级OTA[NRF52832蓝牙OTA]
  • 力扣 hot100 Day30
  • Hadoop WordCount 程序实现与执行指南
  • Python 数据分析与机器学习入门 (三):Pandas 数据导入与核心操作
  • 提示技术系列——链式提示
  • 现代 JavaScript (ES6+) 入门到实战(四):数组的革命 map/filter/reduce - 告别 for 循环
  • stm32 USART串口协议与外设(程序)——江协教程踩坑经验分享
  • 第二届 Parloo杯 应急响应学习——畸形的爱
  • 理解 Confluent Schema Registry:Kafka 生态中的结构化数据守护者
  • Qt事件系统
  • 机器学习在智能电网中的应用:负荷预测与能源管理
  • MySQL锁机制全解析
  • 06_注意力机制
  • Modbus 报文结构与 CRC 校验实战指南(一)
  • leetcode437-路径总和III
  • TVFEMD-CPO-TCN-BiLSTM多输入单输出模型
  • ASP.Net依赖注入!使用Microsoft.Extensions.DependencyInjection配置依赖注入
  • 【ad-hoc】# P12414 「YLLOI-R1-T3」一路向北|普及+
  • MyBatis批量删除
  • 现代 JavaScript (ES6+) 入门到实战(一):告别 var!拥抱 let 与 const,彻底搞懂作用域
  • 数据结构笔记4:数组、链表OJ
  • 华为云 Flexus+DeepSeek 征文|华为云 Flexus 云服务 Dify-LLM 平台深度部署指南:从基础搭建到高可用实践
  • 疏通经脉: Bridge 联通逻辑层和渲染层
  • 使用component封装组件和h函数的用法
  • 数据结构之Map和Set