Linux I/O 多路复用,select / poll / epoll 详解

1. 文件描述符 fd 与 socket#

1.1 什么是文件描述符

文件描述符(file descriptor)是一个非负整数,从 0 开始。进程使用文件描述符来标识一个打开的文件。

系统为每一个进程维护了一个文件描述符表,表示该进程打开文件的记录表,而文件描述符实际上就是这张表的索引。当进程打开(open)或者新建(create)文件时,内核会在该进程的文件列表中新增一个表项,同时返回一个文件描述符 —— 也就是新增表项的下标。

一般来说,每个进程最多可以打开 64 个文件,fd ∈ 0~63。在不同系统上,最多允许打开的文件个数不同,Linux 2.4.22 强制规定最多不能超过 1,048,576。

每个进程默认都有 3 个文件描述符:0 (stdin)、1 (stdout)、2 (stderr)。

1.2 fd 与 socket 关系

socket 是 Unix 中的术语。socket 可以用于同一台主机的不同进程间的通信,也可以用于不同主机间的通信。一个 socket 包含地址、类型和通信协议等信息,通过 socket() 函数创建:

int socket(int domain, int type, int protocol)

返回的就是这个 socket 对应的文件描述符 fd。操作系统将 socket 映射到进程的一个文件描述符上,进程就可以通过读写这个文件描述符来和远程主机通信。

可以这样理解:socket 是进程间通信规则的高层抽象,而 fd 提供的是底层的具体实现。socket 与 fd 是一一对应的。通过 socket 通信,实际上就是通过文件描述符 fd 读写文件。这也符合 Unix“一切皆文件”的哲学。

本文可以将 socket 和 fd 视为同义词。

2. 内核接收网络数据全过程

如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
Linux I/O 多路复用,select / poll / epoll 详解_第1张图片
唤醒进程的过程如下图所示。
Linux I/O 多路复用,select / poll / epoll 详解_第2张图片
以上是内核接收数据全过程
这里有两个问题:
其一,操作系统如何知道网络数据对应于哪个socket?
其二,如何同时监视多个socket的数据?
第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
第二个问题是多路复用的重中之重

3. 从阻塞 I/O 到 I/O 多路复用

阻塞 I/O,是指进程发起调用后,会被挂起(阻塞),直到收到数据再返回。如果调用一直不返回,进程就会一直被挂起。因此,当使用阻塞 I/O 时,需要使用多线程来处理多个文件描述符。

多线程切换有一定的开销,因此引入非阻塞 I/O。非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程里轮询多个文件描述符是否就绪。

但是非阻塞 I/O 的缺点是:每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。

因此引入了 I/O 多路复用,可以通过一次系统调用,检查多个文件描述符的状态。这是 I/O 多路复用的主要优点,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。

I/O 多路复用相当于将「遍历所有文件描述符、通过非阻塞 I/O 查看其是否就绪」的过程从用户线程移到了内核中,由内核来负责轮询。

进程可以通过 select、poll、epoll 发起 I/O 多路复用的系统调用,这些系统调用都是同步阻塞的:如果传入的多个文件描述符中,有描述符就绪,则返回就绪的描述符;否则如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞时长超过设置的 timeout 后,再返回。I/O 多路复用内部使用非阻塞 I/O 检查每个描述符的就绪状态。

如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

I/O 多路复用引入了一些额外的操作和开销,性能更差。但是好处是用户可以在一个线程内同时处理多个 I/O 请求。如果不采用 I/O 多路复用,则必须通过多线程的方式,每个线程处理一个 I/O 请求。后者线程切换也是有一定的开销的。

为什么 I/O 多路复用内部需要使用非阻塞 I/O

I/O 多路复用内部会遍历集合中的每个文件描述符,判断其是否就绪:

for fd in read_set
    ifreadable(fd) ) // 判断 fd 是否就绪
        count++
        FDSET(fd, &res_rset) // 将 fd 添加到就绪集合中
        break
...
return count

这里的 readable(fd) 就是一个非阻塞 I/O 调用。试想,如果这里使用阻塞 I/O,那么 fd 未就绪时,select 会阻塞在这个文件描述符上,无法检查下个文件描述符。

注意:这里说的是 I/O 多路复用的内部实现,而不是说,使用 I/O 多路复用就必须使用非阻塞 I/O,

4. select

函数签名与参数

int select(int nfds,
            fd_set *restrict readfds,
            fd_set *restrict writefds,
            fd_set *restrict errorfds,
            struct timeval *restrict timeout);

readfds、writefds、errorfds 是三个文件描述符集合。select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为“就绪”的描述符。然后用找到的子集替换参数中的对应集合,返回所有就绪描述符的总数。

timeout 参数表示调用 select 时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞。

4.1 fd_set 文件描述符集合

fd_set 结构体:

typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct {
    #ifdef __USE_XOPEN
        __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
    # define __FDS_BITS(set) ((set)->fds_bits)
    #else
        __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
    # define __FDS_BITS(set) ((set)->__fds_bits)
    #endif
} fd_set;

参数中的 fd_set 类型表示文件描述符的集合。

由于文件描述符 fd 是一个从 0 开始的无符号整数,所以可以使用 fd_set 的二进制每一位来表示一个文件描述符。某一位为 1,表示对应的文件描述符已就绪。比如比如设 fd_set 长度为 1 字节,则一个 fd_set 变量最大可以表示 8 个文件描述符。当 select 返回 fd_set = 00010011 时,表示文件描述符 125 已经就绪。

fd_set 的使用涉及以下几个 API:
```c
#include    
int FD_ZERO(int fd, fd_set *fdset);  // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset);   // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set);  // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

4.2 执行流程

Linux I/O 多路复用,select / poll / epoll 详解_第3张图片

  • 用户线程调用select,将fd_set从用户空间拷贝到内核空间
  • 内核在内核空间对fd_set遍历一遍,检查是否有就绪的socket描述符,如果没有的话,就会进入休眠,直到有就绪的socket描述符
  • 内核返回select的结果给用户线程,即就绪的文件描述符数量
  • 用户拿到就绪文件描述符数量后,再次对fd_set进行遍历,找出就绪的文件描述符
  • 用户线程对就绪的文件描述符进行读写操作

select 需要将所有文件描述符传到内核空间里面主要是出于安全考虑:

  1. 用户空间的指针所指向的虚拟地址,在内核态访问的时候,不能保证还存在在物理页面上
  2. 如果在内核态直接访问用户空间的地址,当发生“Page Fault“缺页错误时,内核会死机。而将数据从用户空间拷贝到内核空间的函数 copy_from_user,在发生缺页错误时会优雅地返回一个错误码,保证程序不崩掉。
  3. 为了安全,在读取/写入用户空间的指针之前,必须先验证这个指针是否确实是用户空间的、是否有相应的权限。这是为了防止用户空间访问内核空间。copy_from_user 的入口封装了这个逻辑。
  4. 执行系统调用的线程有可能在验证用户内存之后、但在实际完成系统调用之前被调度出来,这可能导致用户空间的指针,指向了一段包含恶意程序的代码。所以从安全性和(最终)性能等方面来说,先制作一份拷贝会更好。

4.3 select 使用示例

下图的代码说明:

  • 先声明一个 fd_set 类型的变量 readFDs
  • 调用 FD_ZERO,将 readFDs 所有位置 0
  • 调用 FD_SET,将 readFDs 感兴趣的位置 1,表示要监听这几个文件描述符
  • 将 readFDs 传给 select,调用 select
  • select 会将 readFDs 中就绪的位置 1,未就绪的位置 0,返回就绪的文件描述符的数量
  • 当 select 返回后,调用 FD_ISSET 检测给定位是否为 1,表示对应文件描述符是否就绪

比如进程想监听 1、2、5 这三个文件描述符,就将 readFDs 设置为 00010011,然后调用 select。

如果 fd=1、fd=2 就绪,而 fd=5 未就绪,select 会将 readFDs 设置为 00000011 并返回 2。

如果每个文件描述符都未就绪,select 会阻塞 timeout 时长,再返回。这期间,如果 readFDs 监听的某个文件描述符上发生可读事件,则 select 会将对应位置 1,并立即返回。
Linux I/O 多路复用,select / poll / epoll 详解_第4张图片

4.4 select 优点

  1. 跨平台,几乎所有平台都支持;
  2. 时间精度高,ns 级别。

4.5 select 缺点

  1. 最大限制:单个进程能够监视的文件描述符的数量存在最大限制。(基于数组存储的赶脚)一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。它由FD_SETSIZE设置,32位机默认是1024个。64位机默认是2048.
  2. 时间复杂度: 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低,时间复杂度O(n)。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。它仅仅知道有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
  3. 内存拷贝:需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

5. poll

poll 和 select 几乎没有区别。poll 在用户态通过数组方式传递文件描述符,在内核会转为链表方式存储,没有最大数量的限制。

poll 的函数签名

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

pollfd 的结构体

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};

5.1 与select的异同点#

相同点:

  • 内核线程都需要遍历文件描述符,并且当内核返回就绪的文件描述符数量后,还需要遍历一次找出就绪的文件描述符
  • 需要将文件描述符数组或链表从用户空间拷贝到内核空间
    性能开销会随文件描述符的数量而线性增大

不同点:

  • select存储的数据结构是文件描述符数组,poll采用链表
  • select有最大连接数限制,poll没有最大限制,因为poll采用链表存储

5.2 执行过程(基本与 select 类型)

  1. 用户线程调用 poll 系统调用,并将文件描述符链表拷贝到内核空间
  2. 内核对文件描述符遍历一遍,如果没有就绪的描述符,则内核开始休眠,直到有就绪的文件描述符
  3. 返回给用户线程就绪的文件描述符数量
  4. 用户线程再遍历一次文件描述符链表,找出就绪的文件描述符
  5. 用户线程对就绪的文件描述符进行读写操作

5.3 poll 优点

没有最大连接数的限制。(基于链表来存储的)

5.4 poll 缺点

  1. 时间复杂度:对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低,时间复杂度O(n)。它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
  2. 内存拷贝:大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。大量的fd数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
  3. 水平触发:如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

注意:select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

6. epoll

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。

核心代码:

#include 

// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 红黑树用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    ...
    /*红黑树的根节点,这颗树存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root  rbr;
    /*双链表存储所有就绪的文件描述符*/
    struct list_head rdlist;
    ...
};

// API
int epoll_create(int size); // 内核中间加一个 eventpoll 对象,把所有需要监听的 socket 都放到 eventpoll 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 检测双链表中是否有就绪的文件描述符,如果有,则返回

简而言之,epoll 有以下几个特点:

  • 使用红黑树存储文件描述符集合
  • 使用双链表存储就绪的文件描述符
  • 每个文件描述符只需在添加时传入一次,通过事件 callback 更改文件描述符状态。

select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create、epoll_ctl 和 epoll_wait。

  • epoll_create 创建 eventpoll对象(红黑树,双链表)
  • 一棵红黑树,存储监听的所有文件描述符,并且通过 epoll_ctl 将文件描述符添加、删除到红黑树
  • 一个双链表,存储就绪的文件描述符列表,epoll_wait调用时,检测此链表中是否有数据,有的话直接返回
  • 所有添加到 eventpoll 中的事件都与设备驱动程序建立回调关系

6.1 epoll_create#

int epoll_create(int size);

内核在 epoll 文件系统中建了个file结点

  • epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符
  • 返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例
  • 使用完,必须调用close()关闭,否则导致fd被耗尽

epoll 实例内部存储:

  • 监听列表:所有要监听的文件描述符,使用红黑树,由 epoll_ctl 传来
  • 就绪列表:所有就绪的文件描述符,使用双向链表

6.2 epoll_ctl

epoll_ctl 会监听文件描述符 fd 上发生的 event 事件

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数说明

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • fd 表示要监听的目标文件描述符
  • event 表示要监听的事件(可读、可写、发送错误…)
  • op 表示要对 fd 执行的操作,有以下几种:
    • EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
    • EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
    • EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用
  • 返回值 0 或 -1,表示上述操作成功与否。

epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event,如果红黑树中已经存在立刻返回。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

6.3 epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll 模型的主要函数,功能相当于 select

参数说明:

  • epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
  • maxevents 指定 events 的大小
  • timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
  • 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

6.4 mmap 内存映射

Linux I/O 多路复用,select / poll / epoll 详解_第5张图片
Linux I/O 多路复用,select / poll / epoll 详解_第6张图片
Linux I/O 多路复用,select / poll / epoll 详解_第7张图片
Linux I/O 多路复用,select / poll / epoll 详解_第8张图片

6.5 epoll 优点

epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。相当于时间复杂度从 O(n) 降为 O(1)。此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。

  1. 没有最大连接数的限制。(基于 红黑树+双链表 来存储的:1G的内存上能监听约10万个端口)
  2. 时间复杂度低: 边缘触发和事件驱动,监听回调,时间复杂度O(1)。只有活跃可用的fd才会调用callback函数;即epoll最大的优点就在于它只管“活跃”的连接,而跟连接总数无关,因此实际网络环境中,Epoll的效率就会远远高于select和poll。
    3.内存拷贝:利用mmap()文件映射内存加速与内核空间的消息传递,减少拷贝开销。

6.6 epoll 缺点

依赖于操作系统:Linux

6.7 应用场景

适合用epoll的应用场景:

  • 对于连接特别多,活跃的连接特别少
  • 典型的应用场景为一个需要处理上万的连接服务器,例如各种app的入口服务器,例如qq

不适合epoll的场景:

  • 连接比较少,数据量比较大,例如ssh
  • epoll 的惊群问题:因为epoll 多用于多个连接,只有少数活跃的场景,但是万一某一时刻,epoll 等的上千个文件描述符都就绪了,这时候epoll 要进行大量的I/O,此时压力太大。

6.8 epoll 两种模式

epoll对文件描述符的操作有两种模式:LT(level trigger) 和 ET(edge trigger)。LT是默认的模式,ET是“高速”模式。

  • LT(水平触发)模式下,只要有数据就触发,缓冲区剩余未读尽的数据会导致 epoll_wait都会返回它的事件;
  • ET(边缘触发)模式下,只有新数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返回。

水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。

LT 模式

LT(level triggered)是缺省的工作方式,并且同时支持 block 和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。
Linux I/O 多路复用,select / poll / epoll 详解_第9张图片

ET 模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN (提示你的应用程序现在没有数据可读请稍后再试)为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。
Linux I/O 多路复用,select / poll / epoll 详解_第10张图片
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

7. select、poll、epoll的区别Linux I/O 多路复用,select / poll / epoll 详解_第11张图片

select poll epoll
底层数据结构 数组存储文件描述符 链表存储文件描述符 红黑树存储监控的文件描述符,双链表存储就绪的文件描述符
如何从fd数据中获取就绪的fd 遍历fd_set 遍历链表 回调
时间复杂度 获得就绪的文件描述符需要遍历fd数组,O(n) 获得就绪的文件描述符需要遍历fd链表,O(n) 当有就绪事件时,系统注册的回调函数就会被调用,将就绪的fd放入到就绪链表中。O(1)
FD数据拷贝 每次调用select,需要将fd数据从用户空间拷贝到内核空间 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间
最大连接数 有限制,一般为1024 无限制 无限制

注意

  1. 在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。
  2. 如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。

网络编程 / 阻塞io和非阻塞io / io多路复用和边缘触发和水平触发 / reactor 单线程、多线程、多进程应用

网络原理tcp/udp,网络编程epoll/reactor,“八股文”的必要性

Linux后台服务器开发学习资料、视频 有需要的可以自行添加学习交流群960994558

你可能感兴趣的:(Linux,linux,网络,服务器)