Linux五种IO模型浅谈

http://www.ywnds.com/?p=10504

文件描述符

我们知道Linux的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外部设备的操作都可以看做对文件进行操作。我们对一个文件的读写,都通过调用内核提供的系统调用,内核给我们返回一个文件描述符(file descriptor,简称fd)。我们通过ls -l  /proc/${pid}/fd/ 可以看到进程${pid}占用的所有描述符。而对一个socket的读写也会有相应的描述符,称为socket FD(socket描述符),描述符就是一个数字,指向内核中一个结构体;文件路径,数据区,等一些属性。而我们的应用程序对文件的读写就通过对描述符的读写完成。

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。 

进程切换

现代多任务系统,通俗点说就是把CPU的时间进行分片了,在特定的时间片内处理一个特定的系统进程(指单核CPU)。就这样,在多个进程之间利用CPU时间分片来回处理就是我们看到的多任务处理。

当然,为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

1. 保存处理机上下文,包括程序计数器和其他寄存器。

2. 更新PCB信息。

3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。

4. 选择另一个进程执行,并更新其PCB。

5. 更新内存管理的数据结构。

6. 恢复处理机上下文。

PS:总而言之就是很耗资源

缓存IO和直接IO

缓存IO又被称作标准IO,大多数文件系统的默认IO 操作都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存IO的缺点:

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存开销是非常大的。

系统如何调用I/O操作

系统调用是如何完成一个I/O操作的呢? Linux将内存分为内核区和用户区;Linux内核给我们管理所有的硬件资源,应用程序通过调用系统调用和内核交互,达到使用硬件资源的目的;应用程序通过系统调用read()发起一个读操作,这时候内核创建一个文件描述符,并通过驱动程序向硬件发送读指令,并将读的数据放在这个描述符对应结构体的缓存区。但这个结构体是在内核内存区的。所以需要将这个数据复制一份写入到用户区进程空间内,这样完成了一次读操作。

Linux提供的read/write系统调用,都是一个阻塞函数。这样我们的应用进程在发起read/write系统调用时,就必须阻塞,就进程被挂起(进程睡眠)而等待文件描述符的读就绪。

文件描述符读就绪和写就绪

读就绪:就是这个文件描述符的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。

写就绪:该描述符发送缓冲区的可用空间字节数大于等于描述符发送缓冲区低水位标记的当前大小(如果是socket fd,说明上一个数据已经发送完成)。

接收低水位标记和发送低水位标记:由应用程序指定,比如应用程序指定接收低水位为64个字节,那么接收缓冲区有64个字节,才算fd读就绪。

有没有办法能让我们在进行Socket I/O操作时,不让我们的应用进程阻塞。于是对高效的处理I/O的需求就越紧迫了。在人们不断探索中,时至今日一见发明了很多种处理I/O问题的方式,从形式上划分的话,可归类为5大模型:阻塞、非阻塞、多路复用、信号驱动和异步I/O(AIO)

再说一下IO发生时涉及的对象和步骤。对于一个网络IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:

1.等待数据准备 (Waiting for the data to be ready)

2.将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

记住这两点很重要,因为这些IO模型的区别就是在两个阶段上各有不同的情况。

五中IO模型阐述

1)阻塞式I/O

在Linux中,默认情况下所有的socket都是阻塞,一个典型的读操作流程大概是这样:


当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:准备数据,对于网络IO来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存(copy socket data from kenel space to user space.),然后内核返回结果,用户进程才解除阻塞的状态,重新运行起来。所以,阻塞式IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被阻塞了。

几乎所有的程序员第一次接触到的网络编程都是从listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。下面是一个简单地“一问一答”的服务器。


我们注意到,大部分的socket接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。

实际上,除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用send()的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。

一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用pthread_create ()创建新线程,fork()创建新进程。

我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。那么就会出现这个情况,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。这样一来,多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

2)非阻塞式I/O

Linux下,可以通过设置socket使其变为非阻塞式IO。当对一个非阻塞式IO的socket执行读操作时,流程是这个样子:


从图中可以看出,当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它并不会阻塞用户进程,不要将进程睡眠,而是立刻返回一个error。从用户进程角度讲,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作,一旦内核中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,在非阻塞式IO中,用户进程其实是需要不断的主动询问内核数据准备好了没有。

非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄fd设为非阻塞状态。

fcntl( fd, F_SETFL, O_NONBLOCK );

就是在I/O请求时加上O_NONBLOCK一类的标志,立刻返回,但第二阶段依然需要工作进程参与库函数把内核空间数据复制到用户空间,第二阶段依旧阻塞。

下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模型。


在非阻塞状态下,recv()接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,

* recv()返回值大于0,表示接受数据完毕,返回值即是接受到的字节数;

* recv()返回0,表示连接已经正常断开;

* recv()返回-1,且errno等于EAGAIN,表示recv操作还没执行完成;

* recv()返回-1,且errno不等于EAGAIN,表示recv操作遇到系统错误errno;

可以看到服务器线程可以通过循环调用recv()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用recv()将大幅度推高CPU 占用率;此外,在这个方案中recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。

3I/O多路复用

I/O多路复用(IO multiplexing)同阻塞式I/O本质上是一样的,有些地方也称这种IO方式为事件驱动IO(event driven IO),因为利用新的select()、poll()、epoll()等系统调用(函数),由操作系统来负责轮询操作。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个函数会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:


当用户进程调用了select,那么整个进程会被阻塞,而同时,内核会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。

这个图和阻塞式IO的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select和recvfrom),而阻塞式IO只调用了一个系统调用(recvfrom)。但是用select的优势在于它可以同时处理多个connection。(多说一句:所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在多路复用模型中,对于每一个socket,一般都设置成为非阻塞,但是,如上图所示,整个用户的process其实是一直被阻塞的。只不过process是被select这个函数阻塞,而不是被socket IO给阻塞。因此select()与非阻塞IO类似。

下面将重新模拟上例中从多个客户端接收数据的模型。


这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。相比其他模型,使用select()的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。

但这个模型依旧有着很多问题。首先select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如Linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent库,还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。

4)信号驱动I/O模型

信号驱动I/O是一种不常用的I/O模型,首先我们允许套接口进行信号驱动I/O,并安装一个信号处理函数sigaltion,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。


5)异步IO(Asynchronous I/O,AIO)

Linux下的异步IO其实用得不多,从内核2.6版本才开始引入。先看一下它的流程:


工作进程调用I/O库函数epoll,工作进程不会因为I/O操作而阻塞,也不需要轮询,待I/O操作完成后会通知请求者。(异步I/O实现比较复杂但是Nginx支持磁盘异步I/O);LINUX2.6内核才开始支持。

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个Signal,告诉它read操作完成了,在这整个过程中,进程完全没有被阻塞。用异步IO实现的服务器如近年比较流行的Nginx服务器。异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。

总结的IO模型有5种之多:阻塞IO,非阻塞IO,IO复用,信号驱动IO,异步IO。如下图:


到目前为止,已经将五个IO模型都介绍完了。但是我们还经常会听到这么一个概念就是“同步IO(synchronous IO)”和“异步IO(Asynchronous)”,对于这两个概念,POSIX的定义是这样子的:

*一个同步的输入/输出操作会导致请求进程被阻塞,直到完成/输出操作完成;

*一个异步输入/输出操作不会导致请求进程被阻塞;

两者的区别就在于同步IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的阻塞IO,非阻塞IO,多路复用IO都属于同步IO。有人可能会说,非阻塞式IO并没有被阻塞啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个系统调用。非阻塞式IO在执行recvfrom这个系统调用的时候,如果内核的数据没有准备好,这时候不会阻塞进程。但是当内核中数据准备好的时候,recvfrom会将数据从内核拷贝到用户内存中,这个时候进程是被阻塞了,在这段时间内进程是被阻塞的。而异步IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到内核发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被阻塞。

 

所以前四种都属于同步IO。阻塞IO不必说了。非阻塞IO ,IO请求时加上O_NONBLOCK一类的标志位,立刻返回,IO没有就绪会返回错误,需要请求进程主动轮询不断发IO请求直到返回正确。IO复用同非阻塞IO本质一样,不过利用了新的select系统调用,由内核来负责本来是请求进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用开销,不过因为可以支持多路IO,才算提高了效率。信号驱动IO,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。异步IO,如定义所说,不会因为IO操作阻塞,IO操作全部完成才通知请求进程。

C10K问题

Select() & Poll()

Select()调用是I/O多路复用模型下的产物,I/O多路复用实际上是一种复合I/O模型,即利用类似于select的调用对阻塞或非阻塞式I/O的一个集合进行监控,epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。

经典的select处理方式是这样的:调用者将需要监控的I/O句柄(FD)放入一个数组中,将这个数组传递给select调用,并设定监控何种事件,这时select会阻塞调用进程;当有I/O事件发生时,select就在数组中给发生了事件的那些I/O句柄做一个标记后返回;之后,调用者便轮询这个数组,发现被打了标记的便进行相应的处理,并去掉这个标记以备下次使用。这样,对于服务器程序来说,一个进程或线程就可以处理很多客户端的读写请求了。不过select有一个限制,就是传递给它的I/O句柄最多不能超过1024个。于此人们就引入了poll这个调用,poll调用本质上和select没有区别,只是在I/O句柄数理论上没有上限了,原因是它是基于链表来存储的,但是同样有缺点:

1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

2)poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

事情看似解决的不错,可是互联网越来越发达了,上网的人也越来越多了,网络服务器在处理数以万计的客户端连接时,经常会出现效率低下甚至完全瘫痪的局面,这就是非常著名的C10K问题。C10K问题的特点是:一个设计不良好的程序,其性能和连接数,以及机器性能的关系往往是非线性的。换句话说,如果没有考虑C10K问题,一个经典的基于select或poll的程序在旧机器上能很好地处理1000连接,它在2倍性能的新机器上往往处理不了2000并发。这是因为大量的操作的消耗与当前连接数n成线性相关,从而导致单个任务的的资源消耗和当前任务的关系会是O(n)。那么服务器程序同时对数以万计的网络I/O事件进行处理所积累下来的资源消耗会相当可观,结果就是系统吞吐量不能和机器性能匹配。为了解决这个问题,必须改变I/O复用的策略。

为了解决上述这个问题,大神们就发明了epoll、kqueue和/dev/poll这三套利器。其中epoll是Linux的方案,kqueue是freebSD的方案,/dev/poll是最为古老的solaris的方案,使用难度依次递增。这些方案几乎不约而同地做了两件事:

1)是避免每次调用select或poll时内核用于分析参数建立事件等待结构的开销,取而代之的是维护一个长期的事件关注表,应用程序通过句柄修改这个列表和铺货I/O事件。

2)是避免了select或poll返回后,应用程序扫描整个句柄表的开销,取而代之的是直接返回具体的事件列表。这样,就彻底摆脱了具体操作的消耗与当前连接数n的线性关系,从而极大地提高了服务器的处理能力。

Epoll()

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

Epoll的优点

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

select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是1024。对于那些需要支持的上万连接数目的服务器来说显然太少了。Epoll虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max查看,一般来说这个数目和系统内存关系很大。不过这是理论上的,原因就是一个进程所能创建的,或者说能够使用的I/O句柄数是有限制的。默认情况下,Linux允许一个进程对多拥有1024个I/O句柄。虽然可以修改为无限制模式。但是这个不建议,如果要处理上万并发连接的话,最好采用多进程模式,这样不但可以充分利用CPU资源,还可以保证系统的整体稳定性。

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

传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是“活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对“活跃”的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有“活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个“伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

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

这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。不管是什么方案,都是避免不了内核向用户空间传递消息,那么避免不必要的数据拷贝就不失为一个绝妙的办法。mmap就能够做到,因为mmap可以使得内核空间和用户空间的虚拟内存块映射为同一个物理内存块。从而不需要数据拷贝,内核空间和用户空间就可以访问到相同的数据。

最后,epoll可以支持内核微调,不过不能把这个优点完全归epoll所有,这是整个Linux系统的优点,赋予你微调内核的能力,就是procfs文件系统,对内核进行微调的开放接口。

Epoll的工作模型

epoll有两种工作模式,即ET(边沿触发)和LT(水平触发),还可以称为事件触发和条件触发。所谓边沿触发就是当状态有变化时,也就是发生了某种事件就发出通知,而水平触发就是当处于某种状态,也可以说是具备某种条件就发出通知。

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。

ET和LT的区别就在这里体现,LT事件不会丢弃,而是只要读buffer里面有数据可以让用户读,则不断的通知你。而ET则只在事件发生之时通知。可以简单理解为LT是水平触发,而ET则为边缘触发。LT模式只要有事件未处理就会触发,而ET则只在高低电平变换时(即状态从1到0或者0到1)触发。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值