I/O多路复用,我懂了!


I/O

Linux/Unix常见IO模型:阻塞(Blocking I/O)非阻塞(Non-Blocking I/O)IO多路复用(I/O Multiplexing)、 信号驱动 I/O(Signal Driven I/O)(不常用)和异步(Asynchronous I/O)。网络IO操作主要涉及到内核进程,其主要分为两个过程:

  • 内核等待数据可操作(可读或可写)——阻塞与非阻塞
  • 内核与进程之间数据的拷贝——同步与异步

基础概念

① 阻塞(Blocking)和非阻塞(Non-blocking)

阻塞和非阻塞发生在内核等待数据可操作(可读或可写)时,指做事时是否需要等待应答。

  • 阻塞: 内核检查数据不可操作,则不立即返回
  • 非阻塞: 内核检查数据不可操作,则立即返回

② 同步(Synchronous)和异步(Asynchronous)

同步和异步发生在内核与进程交互时,进程触发IO操作后是否需要等待或轮询查看结果。

  • 同步: 触发IO操作 → 等待或轮询查看结果
  • 异步: 触发IO操作 → 直接返回去做其它事,IO处理完后内核主动通知进程

阻塞I/O

阻塞IO情况下,当用户调用read后,用户线程会被阻塞,等内核数据准备好并且数据从内核缓冲区拷贝到用户态缓存区后read才会返回。阻塞分两个阶段:

  • 等待CPU把数据从磁盘读到内核缓冲区
  • 等待CPU把数据从内核缓冲区拷贝到用户缓冲区

阻塞IO

非阻塞I/O

非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,询问数据是否准备好,当数据没有准备好时,内核立即返回EWOULDBLOCK错误。直到数据准备好后,内核将数据拷贝到应用程序缓冲区,read 请求才获取到结果。

注意:这里最后一次 read 调用获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程

非阻塞IO

注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。

I/O多路复用

非阻塞情况下无可用数据时,应用程序每次轮询内核看数据是否准备好了也耗费CPU,能否不让它轮询,当内核缓冲区数据准备好了,以事件通知当机制告知应用进程数据准备好了呢?应用进程在没有收到数据准备好的事件通知信号时可以忙写其他的工作。此时IO多路复用就派上用场了。像select、poll、epoll 都是I/O多路复用的具体的实现。

IO-多路复用

同步I/O

无论 read 和 send 是 阻塞I/O,还是 非阻塞I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,即这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。

异步I/O

真正的异步 I/O 是内核数据准备好 和 数据从内核态拷贝到用户态 这两个过程都不用等待。

当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:

异步IO

Reactor模式

Reactor 模式 即 I/O 多路复用监听事件,收到事件后根据事件类型分配(Dispatch)给某个进程/线程。其主要由 Reactor 和 处理资源池 两个核心部分组成:

  • Reactor:负责监听和分发事件。事件类型包含连接事件、读写事件
  • 处理资源池:负责处理事件。如:read -> 业务逻辑 -> send

Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:

  • Reactor 的数量可以只有一个,也可以有多个
  • 处理资源池可以是单个进程/线程,也可以是多个进程/线程

将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:

  • 单 Reactor 单进程/线程
  • 单 Reactor 多进程/线程
  • 多 Reactor 单进程/线程:相比 单Reactor单进程/线程 方案不仅复杂而且没有性能优势,因此可以忽略
  • 多 Reactor 多进程/线程

单Reactor单进程/单线程

一般来说,C 语言实现的是单Reactor单进程的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。以下是「单 Reactor单进程」的方案示意图:

单Reactor单进程线程

可以看到进程里有 ReactorAcceptorHandler 这三个对象:

  • Reactor 对象的作用是监听和分发事件
  • Acceptor 对象的作用是获取连接
  • Handler 对象的作用是处理业务

对象里的 selectacceptreadsend 是系统调用函数,dispatch 和 业务处理 是需要完成的操作,其中 dispatch 是分发事件操作。

工作流程

  • Reactor 对象通过 select (IO多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程

优缺点

  • 优点

    • 因为全部工作都在同一个进程内完成,所以实现起来比较简单
    • 不需要考虑进程间通信,也不用担心多进程竞争
  • 缺点

    • 因为只有一个进程,无法充分利用 多核 CPU 的性能
    • Handler对象在业务处理时,整个进程是无法处理其它连接事件,如果业务处理耗时比较长,那么就造成响应的延迟

使用场景

单Reactor单进程的方案不适用计算机密集型的场景只适用于业务处理非常快速的场景。如:Redis 是由 C 语言实现的,它采用的正是「单Reactor单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。

单Reactor多线程/多进程

如果要克服单 Reactor 单线程/单进程方案的缺点,那么就需要引入多线程/多进程,这样就产生了单Reactor多线程/多进程的方案。具体方案的示意图如下:

单Reactor多线程多进程

工作流程

  • Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型
  • 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法获取连接,并创建一个 Handler 对象来处理后续的响应事件
  • 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应
  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client

单Reator多线程

  • 优势:能够充分利用多核 CPU 的能力

  • 缺点:带来了多线程竞争资源问题(如需加互斥锁解决)

单Reactor多进程

  • 缺点
    • 需要考虑子进程和父进程的双向通信
    • 进程间通信远比线程间通信复杂

另外,单Reactor 的模式还有个问题,因为一个 Reactor 对象承担所有事件的 监听 和 响应 ,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能瓶颈。

多Reactor多进程/多线程

要解决 单Reactor 的问题,就是将 单Reactor 实现成 多Reactor,这样就产生了 多Reactor多进程/线程 方案。其方案的示意图如下(以线程为例):

多Reactor多进程线程

工作流程

  • 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程
  • 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件
  • 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应
  • Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程

方案优势

多Reactor多线程 的方案虽然看起来复杂的,但是实际实现时比 单Reactor多线程的方案要简单的多,原因如下:

  • 分工明确:主线程只负责接收新连接,子线程负责完成后续的业务处理
  • 主线程和子线程的交互很简单:主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端

应用场景

  • 多Reactor多线程:开源软件 NettyMemcache

  • 多Reactor多进程:开源软件 Nginx。不过 Nginx 方案与标准的多Reactor多进程有些差异,具体差异:

    • 主进程仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而由子进程的 Reactor 来 accept 连接
    • 通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程

Proactor模式

Reactor 和 Proactor 的区别

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件
    • 在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据
    • 简单理解:来了事件(有新连接、有数据可读、有数据可写)操作系统通知应用进程,让应用进程来处理(从驱动读取到内核以及从内核读取到用户空间)
  • Proactor 是异步网络模式, 感知的是已完成的读写事件
    • 在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据
    • 简单理解:来了事件(有新连接、有数据可读、有数据可写)操作系统来处理(从驱动读取到内核,从内核读取到用户空间),处理完再通知应用进程

无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件

Proactor 模式的示意图如下:

Proactor模式

工作流程

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过
  • Asynchronous Operation Processor 注册到内核
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理
  • Handler 完成业务处理

平台支持

  • Linux:在 Linux 下的 异步I/O 是不完善的,aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步。并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案
  • Windows :在 Windows 下实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的 异步I/O,真正意义上 异步I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案

select/poll/epoll

select、poll、epoll对比

注意遍历相当于查看所有的位置,回调相当于查看对应的位置。

select

select工作流程

select 本质上是通过设置或者检查存放 fd 标志位的数据结构来进行下一步处理。

缺点

  • 单个进程可监视的fd数量被限制。能监听端口的数量有限,数值存在文件:cat /proc/sys/fs/file-max
  • 需要维护一个用来存放大量fd的数据结构。这样会使得用户空间和内核空间在传递该结构时复制开销大
  • 对fd进行扫描时是线性扫描fd剧增后,IO效率较低,因为每次调用都对fd进行线性扫描遍历,所以随着fd的增加会造成遍历速度慢的性能问题
  • select()函数的超时参数在返回时也是未定义的。考虑到可移植性,每次在超时之后在下一次进入到select之前都需要重新设置超时参数

优点

  • select()的可移植性更好。在某些Unix系统上不支持poll()
  • select()对于超时值提供了更好的精度:微秒。而poll是毫秒

poll

poll工作流程

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

缺点

  • 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义
  • 与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符

优点

  • poll() 不要求开发者计算最大文件描述符加一的大小
  • poll() 在应付大数目的文件描述符的时候速度更快,相比于select
  • 它没有最大连接数的限制,原因是它是基于链表来存储的

epoll

epoll工作流程

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

优点

  • 支持一个进程打开大数目的socket描述符(FD)

    select最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是1024/2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

  • IO效率不随FD数目增加而线性下降

    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在Linux内核。

  • 使用mmap加速内核与用户空间的消息传递

    这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。

BIO(同步阻塞I/O)

每个客户端的Socket连接请求,服务端都会对应有个处理线程与之对应,对于没有分配到处理线程的连接就会被阻塞或者拒绝。相当于是一个连接一个线程

用户需要等待read将socket中的数据读取到buffer后,才继续处理接收的数据。整个IO请求的过程中,用户线程是被阻塞的,这导致用户在发起IO请求时,不能做任何事情,对CPU的资源利用率不够。

同步阻塞IO

**特点:**I/O执行的两个阶段进程都是阻塞的。

  • 使用一个独立的线程维护一个socket连接,随着连接数量的增多,对虚拟机造成一定压力
  • 使用流来读取数据,流是阻塞的,当没有可读/可写数据时,线程等待,会造成资源的浪费

优点

  • 能够及时的返回数据,无延迟
  • 程序简单,进程挂起基本不会消耗CPU时间

缺点

  • I/O等待对性能影响较大
  • 每个连接需要独立的一个进程/线程处理,当并发请求量较大时为了维护程序,内存、线程和CPU上下文切换开销较大,因此较少在开发环境中使用

NIO(同步非阻塞I/O)

服务器端保存一个Socket连接列表,然后对这个列表进行轮询:

  • 如果发现某个Socket端口上有数据可读时说明读就绪,则调用该Socket连接的相应读操作
  • 如果发现某个Socket端口上有数据可写时说明写就绪,则调用该Socket连接的相应写操作
  • 如果某个端口的Socket连接已经中断,则调用相应的析构方法关闭该端口

这样能充分利用服务器资源,效率得到了很大提高,在进行I/O操作请求时候再用个线程去处理,是一个请求一个线程。Java中使用Selector、Channel、Buffer来实现上述效果。

  • Selector:Selector允许单线程处理多个Channel。如果应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,得向Selector注册Channel,然后调用他的select方法,这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子入有新连接接进来,数据接收等。
  • Channel:基本上所有的IO在NIO中都从一个Channel开始。Channel有点像流,数据可以从channel到buffer,也可以从buffer到channel。
  • Buffer:缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松的使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变换情况,Channel提供从文件,网络读取数据的渠道,但是读取或者写入的数据都必须经由Buffer。

用户需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。整个IO请求过程中,虽然用户线程每次发起IO请求后可以立即返回,但为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。

同步非阻塞IO

**特点:**non-blocking I/O模式需要不断的主动询问kernel数据是否已准备好。

优点

  • 进程在等待当前任务完成时,可以同时执行其他任务进程不会被阻塞在内核等待数据过程,每次发起的I/O请求会立即返回,具有较好的实时性

缺点

  • 不断轮询将占用大量CPU时间,系统资源利用率大打折扣,影响性能,整体数据吞吐量下降
  • 该模型不适用web服务器

IO多路复用(异步阻塞I/O)

通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。

IO多路复用

**特点:**通过一种机制能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个变为可读就绪状态,select()/poll()函数就会返回。

优点

  • 可以基于一个阻塞对象,同时在多个描述符上可读就绪,而不是使用多个线程(每个描述符一个线程),即能处理更多的连接
  • 可以节省更多的系统资源

缺点:

  • 如果处理的连接数不是很多的话,使用select/poll的web server不一定比使用multi-threading + blocking I/O的web server性能更好
  • 可能延迟还更大,因为处理一个连接数需要发起两次system call

AIO(异步非阻塞I/O)

AIO(异步非阻塞IO,即NIO.2)。异步IO模型中,用户线程直接使用内核提供的异步IO API发起read请求,且发起后立即返回,继续执行用户线程代码。不过此时用户线程已经将调用的AsynchronousOperation和CompletionHandler注册到内核,然后操作系统开启独立的内核线程去处理IO操作。当read请求的数据到达时,由内核负责读取socket中的数据,并写入用户指定的缓冲区中。最后内核将read的数据和用户线程注册的CompletionHandler分发给内部Proactor,Proactor将IO完成的信息通知给用户线程(一般通过调用用户线程注册的完成事件处理函数),完成异步IO。

异步非阻塞IO

**特点:**第一阶段和第二阶段都是有内核完成。

优点

  • 能充分利用DMA的特性,将I/O操作与计算重叠,提高性能、资源利用率与并发能力

缺点

  • 在程序的实现上比较困难
  • 要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 复用式I/O模型为主

信号驱动式I/O

信号驱动式I/O是指进程预先告知内核,使得某个文件描述符上发生了变化时,内核使用信号通知该进程。在信号驱动式I/O模型,进程使用socket进行信号驱动I/O,并建立一个SIGIO信号处理函数,当进程通过该信号处理函数向内核发起I/O调用时,内核并没有准备好数据报,而是返回一个信号给进程,此时进程可以继续发起其他I/O调用。也就是说,在第一阶段内核准备数据的过程中,进程并不会被阻塞,会继续执行。当数据报准备好之后,内核会递交SIGIO信号,通知用户空间的信号处理程序,数据已准备好;此时进程会发起recvfrom的系统调用,这一个阶段与阻塞式I/O无异。也就是说,在第二阶段内核复制数据到用户空间的过程中,进程同样是被阻塞的。

信号驱动式I/O的整个过程图如下:

信号驱动式IO

第一阶段(非阻塞):

  • ①:进程使用socket进行信号驱动I/O,建立SIGIO信号处理函数,向内核发起系统调用,内核在未准备好数据报的情况下返回一个信号给进程,此时进程可以继续做其他事情
  • ②:内核将磁盘中的数据加载至内核缓冲区完成后,会递交SIGIO信号给用户空间的信号处理程序

第二阶段(阻塞):

  • ③:进程在收到SIGIO信号程序之后,进程向内核发起系统调用(recvfrom)
  • ④:内核再将内核缓冲区中的数据复制到用户空间中的进程缓冲区中(真正执行IO过程的阶段),直到数据复制完成
  • ⑤:内核返回成功数据处理完成的指令给进程;进程在收到指令后再对数据包进程处理;处理完成后,此时的进程解除不可中断睡眠态,执行下一个I/O操作

**特点:**借助socket进行信号驱动I/O并建立SIGIO信号处理函数

优点

  • 线程并没有在第一阶段(数据等待)时被阻塞,提高了资源利用率;

缺点

  • 在程序的实现上比较困难
  • 信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失

信号通知机制

  • **水平触发:**指数据报到内核缓冲区准备好之后,内核通知进程后,进程因繁忙未发起recvfrom系统调用;内核会再次发送通知信号,循环往复,直到进程来请求recvfrom系统调用。很明显,这种方式会频繁消耗过多的系统资源
  • **边缘触发:**内核只会发送一次通知信号
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值