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

操作系统-IO多路复用

部分内容来源:JavaGuide


快速复习

基本Socket模型:

双方进行网络通信前,需各自创建一个 Socket,相当于客户端和服务器开了个 【口子】

读写数据都通过它

服务端

  1. 调用 socket () 函数,创建网络协议为 IPv4、传输协议为 TCP 的 Socket。
  2. 调用 bind () 函数,给 Socket 绑定 IP 地址和端口
    绑定端口目的是内核通过 TCP 头端口号找到应用程序并传递数据
    绑定 IP 地址目的是机器多网卡时,内核仅将绑定网卡的包发给程序
  3. 绑定后调用 listen () 函数监听,对应 TCP 状态图中的 listen,可通过 netstat 命令查看端口是否被监听
  4. 进入监听状态后,调用 accept () 函数从内核获取客户端连接,无连接时阻塞等待

客户端:

在服务端创建好 Socket 后

客户端调用 connect () 函数发起连接,函数参数指明服务端 IP 地址和端口号,随后开始 TCP 三次握手


在 TCP 连接过程中,服务器内核为每个 Socket 维护两个队列:

  • TCP 半连接队列:“还没完全建立” 的连接队列,队列中的连接未完成三次握手,服务端处于 syn_rcvd 状态
  • TCP 全连接队列:“已经建立” 的连接队列,队列中的连接完成了三次握手,服务端处于 established 状态

如何服务更多的用户:

TCP Socket调用很简单,但它只能执行一对一通信,因为使用的通信方式是同步阻塞

TCP四元组:本机IP, 本机端口, 对端IP, 对端端口

因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的

所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

公式计算->单机最大 TCP 连接数约为 2 的 48 次方

但这只是理论,服务器肯定承载不了那么大的连接数

最大连接数主要会受两个方面的限制:

  1. 文件描述符,一个Socket对应一个文件描述符,文件描述符默认值是 1024,不过我们可以通过 ulimit 增大文件描述符的数目
  2. 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的

多进程模型:

服务器要支持多个客户端,那么可有使用多进程模型,也就是为每个客户端分配一个进程来处理请求

子进程会复制父进程的文件描述符,所以子进程可以直接使用「已连接Socket」和客户端通信子进程不需要关心「监听Socket」,只需要关心「已连接Socket」

父进程将客户服务交给子进程来处理

因此父进程不需要关心「已连接Socket」,只需要关心「监听Socket」


多线程模型:

进程里的线程可以共享进程的部分资源

比如:文件描述符列表、进程空间、代码、全局数据、堆、共享库等

这些共享资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据

因此同一个进程下的线程上下文切换的开销要比进程小得多

线程池可以解决线程的频繁创建和销毁

我们会把连接好的Socket放到一个队列里面存着,然后线程从队列中获取对应的 Socket 任务


IO多路复用:

一个进程维护多个Socket,把时间拉长来看就是多个请求复用了一个进程,这就是多路复用

进程可以通过一个系统调用函数从内核中获取多个事件


select/poll/epoll 是如何获取网络事件的呢?

  1. 获取事件时,先把所有连接(文件描述符)传给内核
  2. 内核返回产生了事件的连接

3。在用户态中再处理这些连接对应的请求即可


为什么系统文件描述符会受限?

文件描述符(FD)是操作系统用来 跟踪和管理打开的文件、Socket、管道等资源 的标识符

但系统会限制单个进程能使用的 FD 数量

原因:

  1. 防止系统资源被耗尽
  2. 防止恶意程序打开海量文件和占用所有Scoket的情况发生?

文件描述符的作用:

跟踪和管理打开的文件、Socket、管道等资源

  1. 统一资源访问:文件描述符将不同的资源(如普通文件、设备、管道、Socket等)抽象为统一的整数标识符,使进程可以通过相同的系统调用(如 read()、write())操作这些资源
  2. 进程级资源管理:每个进程独立维护自己的文件描述符表,记录当前打开的资源

select:

select 是最古老的多路复用机制之一,它使用一个文件描述符集合来监视多个文件描述符的状态变化

在 Windows 和 Unix-like 系统上都有实现

它有一个限制 即最大监视的文件描述符数量默认情况是 1024

每次调用 select 都需要将文件描述符的集合从用户空间拷贝到内核空间,性能较差


poll:

poll 是对 select 的改进,同样也是使用文件描述符集合来监视多个文件描述符的状态变化

在 Unix-like 系统上实现

select 相比,poll 没有最大文件描述符数量的限制

调用 poll 时只需将文件描述符集合的指针传递给内核,无需拷贝,但仍然存在效率问题


select和poll都是遍历文件描诉符来找到资源的

select和poll是基于轮询的,epoll是基于事件驱动的


epoll:

epoll 是 Linux 特有的,是 selectpoll 的进一步改进,尤其适用于高并发的网络应用。

使用一种基于事件的模型而不是基于轮询

epoll 使用三个 API:

  1. epoll_create 创建 epoll 实例
  2. epoll_ctl 添加、修改或删除感兴趣的文件描述符
  3. epoll_wait 等待文件描述符上的事件

相比于 selectpollepoll 可以监视大量的文件描述符,并且性能随着文件描述符数量的增加而不断提高

因为它使用了红黑树或哈希表来存储文件描述符

而不是遍历整个描述符集合

总的来说,epoll 是最好的选择,特别是在高并发的网络编程中,因为它的性能更好,支持更多的文件描述符,并且采用了基于事件的模型,避免了轮询的开销


epoll的水平触发和边缘触发:

水平触发:

当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次

即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次

因此我们程序要保证一次性将内核缓冲区的数据读取完

边缘触发:

当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒

直到内核缓冲区数据被 read 函数读完才结束

边缘触发的目的是告诉我们有数据需要读取


最基本的Socket模型

要想客户端和服务器能在网络中通信,必须使用 Socket 编程

它是进程间通信比较特别的方式,特别之处在于可跨主机间通信

Socket 中文名叫插口(套接字)

双方进行网络通信前,需各自创建一个 Socket,相当于客户端和服务器开了个 【口子】

读写数据都通过它

创建 Socket 时,可指定网络层使用 IPv4 还是 IPv6,传输层使用 TCP 还是 UDP

UDP 的 Socket 编程相对简单,这里只介绍基于 TCP 的 Socket 编程


服务端Socket编程

服务器程序需先运行,等待客户端连接和数据

服务端

  1. 调用 socket () 函数,创建网络协议为 IPv4、传输协议为 TCP 的 Socket。
  2. 调用 bind () 函数,给 Socket 绑定 IP 地址和端口
    绑定端口目的是内核通过 TCP 头端口号找到应用程序并传递数据
    绑定 IP 地址目的是机器多网卡时,内核仅将绑定网卡的包发给程序
  3. 绑定后调用 listen () 函数监听,对应 TCP 状态图中的 listen,可通过 netstat 命令查看端口是否被监听
  4. 进入监听状态后,调用 accept () 函数从内核获取客户端连接,无连接时阻塞等待

客户端:

在服务端创建好 Socket 后

客户端调用 connect () 函数发起连接,函数参数指明服务端 IP 地址和端口号,随后开始 TCP 三次握手

服务端socket(),bind(),listen(),accept()

客户端connnet()发起连接


TCP连接过程

在 TCP 连接过程中,服务器内核为每个 Socket 维护两个队列:

  • TCP 半连接队列:“还没完全建立” 的连接队列,队列中的连接未完成三次握手,服务端处于 syn_rcvd 状态
  • TCP 全连接队列:“已经建立” 的连接队列,队列中的连接完成了三次握手,服务端处于 established 状态

当 TCP 全连接队列不为空时

服务端的 accept () 函数从内核的 TCP 全连接队列中取出一个已完成连接的 Socket 返回应用程序,后续用其传输数据

监听的 Socket 和真正用来传数据的 Socket 是两个:

  • 监听 Socket
  • 已连接 Socket

连接建立后,客户端和服务端通过 read () write () 函数读写数据


图解


如何服务更多的用户

前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信

因为使用的是同步阻塞的方式

当服务器在还没处理完一个客户端时网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的

可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。

在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?

相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。

因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的

所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是**服务端单机最大 TCP 连接数约为 2 的 48 次方

这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:

-文件描述符Socket 实际上是一个文件,也就会对应一个文件描述符。

在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;

- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的

那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?

并发 1 万请求,也就是经典的 C10K 问题,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。

从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。

不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远


多进程模型

进程处理请求

基于最原始的阻塞网络I/O,如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型

也就是为每个客户端分配一个进程来处理请求

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept()函数就会返回一个「已连接Socket」

这时就通过 `fork()` 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。

这两个进程刚复制完的时候,几乎一模一样

不过,会根据返回值来区分是父进程还是子进程

如果返回值是0,则是子进程

如果返回值是其他的整数,就是父进程

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接Socket」和客户端通信了。

可以发现,子进程不需要关心「监听Socket」,只需要关心「已连接Socket」

父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接Socket」,只需要关心「监听Socket」


回收资源

另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程。随着僵尸进程越多,会慢慢耗尽我们的系统资源

因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源

分别

1.调用 wait()

2.waitpid() 函数

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣

进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源


多线程模型

既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求—多线程模型

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等

这些共享资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据

因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP 完成连接后,通过 `pthread_create()` 函数创建线程

然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的

那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁

所谓的线程池,就是提前创建若干个线程,这样当中新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket」进行处理

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死打也是打不住的


 IO多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?

答案是有的,那就是 I/O 多路复用技术

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,

种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用

进程可以通过一个系统调用函数从内核中获取多个事件

select/poll/epoll 是如何获取网络事件的呢?

在获取事件时,先把所有连接(文件描述符)传给内核

再由内核返回产生了事件的连接

然后在用户态中再处理这些连接对应的请求即可

select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们


为什么系统文件描述符会受限

文件描述符(FD)是操作系统用来 跟踪和管理打开的文件、Socket、管道等资源 的标识符

但系统会限制单个进程能使用的 FD 数量

原因:

  1. 防止系统资源被耗尽
  2. 防止恶意程序打开海量文件和占用所有Scoket的情况发生?

操作系统的文件描述符是用来干嘛的

文件描述符(File Descriptor,简称 fd) 是一个用于访问文件或输入/输出资源的抽象标识符

文件描述符的作用:跟踪和管理打开的文件、Socket、管道等资源

  1. 统一资源访问
    文件描述符将不同的资源(如普通文件、设备、管道、Socket等)抽象为统一的整数标识符,使进程可以通过相同的系统调用(如 read()write())操作这些资源。
  2. 进程级资源管理
    每个进程独立维护自己的文件描述符表,记录当前打开的资源。描述符的值通常是非负整数(如 012……),由内核动态分配

select/poll

select 实现多路复用的方式

将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里让内核来检查是否有网络事件产生检查的方式很粗暴,就是通过遍历文件描述符集合的方式

当检查到事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里

然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合

一次是在内核态里,一个次是在用户态里

而且还会发生 2 次「拷贝」文件描述符集合

先从用户空间传入内核空间,由内核修改后,再传出到用户空间中

select 使用固定长度的 BitsMap表示文件描述符集合,而且所支持的文件描述符的个数是有限制的

在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 \(1024\),只能监听 0~1023 的文件描述符


poll实现多路复用的方式

poll 不再用 BitsMap 来存储所关注的文件描述符

取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制

当然还会受到系统文件描述符限制

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合

因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 (O(n)),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上乘,性能的损耗会呈指数级增长


epoll

先复习下epoll的用法。如下的代码中,先用epoll_create创建一个epoll对象epfd,再通过epoll_ctl将需要监视的socket添加到epfd中,最后调用epoll_wait等待数据。

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中while(1) {int n = epoll_wait(...);for(接收到数据的socket){//处理}
}

epoll 通过两个方面,很好解决了 select/poll 的问题

第一点:红黑树保存socket

epoll在内核使用红黑树跟踪进程所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里

红黑树是个高效的数据结构,增删改一般时间复杂度是O(logn)

而select/poll内核里没有类似epoll红黑树这种保存所有待检测的socket的数据结构

所以select/poll每次操作时都传入整个socket集合给内核,每次都全量遍历检查

而epoll因为在内核维护了红黑树,可以保存所有待检测的socket,所以只需要传入一个待检测的socket

减少了内核和用户空间大量的数据拷贝和内存分配


第二点:事件驱动机制(回调)

epoll使用事件驱动的机制,内核里维护了一个链表记录就绪事件

当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中

当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数

不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率

从下图你可以看到 epoll 相关的接口作用:

epoll的方式即使监听的Socket数量越多的时候,效率不会大幅度降低,能够同时监听的Socket的数目也非常多

上限就为系统定义的进程打开的最大文件描述符个数

因而,epoll被称为解决C10K问题的利器


题外话

插个题外话,网上文章不少说,epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。

这是错误的!看过epoll内核源码的都知道,压根就没有使用共享内存这个玩意

你可以从下面这份代码看到,epoll_wait实现的内核代码中调用了__put_user函数,这个函数就是将数据从内核拷贝到用户空间。


边缘触发和水平触发

epoll 支持两种事件触发模式

分别是

边缘触发(edge - triggered,ET)

水平触发(level - triggered,LT)

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取

例子

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;

如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;

边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。


如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。


如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。

因此,我们会循环从文件描述符读写数据

那么如果文件描述符是阻塞的,没有数据可读时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用

程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。


一般来说,边缘触发的效率比水平触发的效率高,因为边缘触发可以减少 epoll_wait 的系统调用次数

系统调用也是有一定的开销的,毕竟也存在上下文的切换

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式

IO多路复用搭配非阻塞IO

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用

在 Linux 下,select () 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况

也有可能在其他情况下,文件描述符被错误地报告为就绪

因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全

简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况


epoll的水平触发和边缘触发

水平触发

当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次

即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次

因此我们程序要保证一次性将内核缓冲区的数据读取完


边缘触发

当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒

直到内核缓冲区数据被 read 函数读完才结束

边缘触发的目的是告诉我们有数据需要读取

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

相关文章:

  • 深度学习核心:从基础到前沿的全面解析
  • 约束-1-约束
  • 【论文笔记】A Deep Reinforcement Learning Based Real-Time Solution Policy for the TSP
  • leetcode 226 翻转二叉树
  • openEuler 24.03 (LTS-SP1) 下安装 K8s 集群 + KubeSphere 遇到 etcd 报错的解决方案
  • Qt:按像素切割图片
  • 制胶学习分享
  • FFmpeg在Go、Python、C++、Rust实践案例
  • vue3 el-table 列汉字 排序时排除 null 或空字符串的值
  • rust cargo 编译双架构的库
  • 构建InfluxDB 3 Python插件深入实践指南
  • DDL期间TDSQL异常会话查询造成数据库主备切换
  • linux环境下安装和配置MySQL数据库
  • 关于市场主流自动化测试工具和框架的简要介绍
  • MySQL主键深度解析:数据库设计的核心基石
  • Java学习---JVM(1)
  • 字节跳动高质量声音克龙文字转语音合成软件MegaTTS3整合包
  • 依存句法分析:语言结构的骨架解码器
  • 岛津液相色谱仪配置RF-20AXS荧光检测器的测试安装,校准
  • Ansible:强大的自动部署工具
  • SPGAN: Siamese projection Generative Adversarial Networks
  • 开源 Canvas 和 WebGL 图形库推荐与对比
  • OpenCV 4.10.0 移植 - Android
  • 跨境电商税务解决之道:在合规航道上驶向全球市场
  • Elasticsearch 简介
  • 集成CommitLInt+ESLint+Prettier+StyleLint+LintStaged
  • 节日庆典儿童节婚庆运动会劳动节PPT模版
  • Android Studio 打 release 包 Algorithm HmacPBESHA256 not available 问题解决
  • 【arXiv 2025】新颖方法:基于快速傅里叶变换的高效自注意力,即插即用!
  • 多样化消费摄像头监控功能