IO模式和IO多路复用

网络编程里常听到阻塞IO、非阻塞IO、同步IO、异步IO等概念,总听别人装13不如自己下来钻研一下。不过,搞清楚这些概念之前,还得先回顾一些基础的概念。

 

1 基础知识回顾

注意:咱们下面说的都是Linux环境下,跟Windows不一样哈~~~

1.1 用户空间和内核空间

  现在操作系统都采用虚拟寻址,处理器先产生一个虚拟地址,通过地址翻译成物理地址(内存的地址),再通过总线的传递,最后处理器拿到某个物理地址返回的字节。

  对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

补充:地址空间就是一个非负整数地址的有序集合。如{0,1,2...}。

1.2 进程上下文切换(进程切换)

  为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(也叫调度)。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

  从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化
  1. 保存当前进程A的上下文

  上下文就是内核再次唤醒当前进程时所需要的状态,由一些对象(程序计数器、状态寄存器、用户栈等各种内核数据结构)的值组成。

  这些值包括描绘地址空间的页表、包含进程相关信息的进程表、文件表等。
  2. 切换页全局目录以安装一个新的地址空间

    ...
  3. 恢复进程B的上下文

  可以理解成一个比较耗资源的过程。

1.3 进程的阻塞

  正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

1.4 文件描述符

  文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

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

1.5 直接I/O和缓存I/O

  缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,以write为例,数据会先被拷贝进程缓冲区,在拷贝到操作系统内核的缓冲区中,然后才会写到存储设备中。

缓存I/O的write:

直接I/O的write:(少了拷贝到进程缓冲区这一步)

 

write过程中会有很多次拷贝,知道数据全部写到磁盘。好了,准备知识概略复习了一下,开始探讨IO模式。

2 I/O模式

  对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段:
  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:
  -- 阻塞 I/O(blocking IO)
  -- 非阻塞 I/O(nonblocking IO)
  -- I/O 多路复用( IO multiplexing)
  -- 信号驱动 I/O( signal driven IO)
  -- 异步 I/O(asynchronous IO)

  注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO 模型。

2.1 block I/O模型(阻塞I/O)

阻塞I/O模型示意图:

read为例:

(1)进程发起read,进行recvfrom系统调用;

(2)内核开始第一阶段,准备数据(从磁盘拷贝到缓冲区),进程请求的数据并不是一下就能准备好;准备数据是要消耗时间的;

(3)与此同时,进程阻塞(进程是自己选择阻塞与否),等待数据ing;

(4)直到数据从内核拷贝到了用户空间,内核返回结果,进程解除阻塞。

也就是说,内核准备数据数据从内核拷贝到进程内存地址这两个过程都是阻塞的。

 

2.2 non-block(非阻塞I/O模型)

可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:

 

  

  (1)当用户进程发出read操作时,如果kernel中的数据还没有准备好;

  (2)那么它并不会block用户进程,而是立刻返回一个error,从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果;

  (3)用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call;

  (4)那么它马上就将数据拷贝到了用户内存,然后返回。

  所以,nonblocking IO的特点是用户进程内核准备数据的阶段需要不断的主动询问数据好了没有

 

2.3 I/O多路复用

    I/O多路复用实际上就是用select, poll, epoll监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。当然具体区别我们后面再讨论,现在先来看下I/O多路复用的流程:

  (1)当用户进程调用了select,那么整个进程会被block;

      (2)而同时,kernel会“监视”所有select负责的socket;

  (3)当任何一个socket中的数据准备好了,select就会返回;

  (4)这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

  所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回

  这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

  所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用多线程 + 阻塞 IO的web server性能更好,可能延迟还更大。

  select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

  在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

 

 2.4 asynchronous I/O(异步 I/O)

  真正的异步I/O很牛逼,流程大概如下:

 

 

 

(1)用户进程发起read操作之后,立刻就可以开始去做其它的事。

(2)而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。

(3)然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

 

2.5 小结

(1)blocking和non-blocking的区别

  调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

(2)synchronous IO和asynchronous IO的区别

  在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
    - A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
    - An asynchronous I/O operation does not cause the requesting process to be blocked;

  两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

  有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

  而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

(3)non-blocking IO和asynchronous IO的区别

  可以发现non-blocking IO和asynchronous IO的区别还是很明显的。

  --在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要程主动的再次调用recvfrom来将数据拷贝到用户内存

  --而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据

 

 

3 事件驱动编程模型

3.1论事件驱动

  通常,我们写 服务器处理模型的程序时,有以下几种模型
    (1)每收到一个请求,创建一个新的进程,来处理该请求;
    (2)每收到一个请求,创建一个新的线程,来处理该请求;
    (3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求
  上面的几种方式,各有千秋:
    第(1)中方法,由于创建新的进程:实现比较简单,但开销比较大,导致服务器性能比较差。
    第(2)种方式,由于要涉及到线程的同步,有可能会面临死锁等问题。
    第(3)种方式,在写应用程序代码时,逻辑比前面两种都复杂。
  综合考虑各方面因素,一般普遍认为 第(3)种方式是大多数网络服务器采用的方式
 
3.2 看图说话讲事件驱动模型

  在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢?
  方式一:创建一个线程,该线程一直循环检测是否有鼠标点击,那么这个方式有以下几个缺点
    1. CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
    2. 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
    3. 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题;
  所以,该方式是非常不好的。

方式二:就是事件驱动模型
  目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:
    1. 有一个事件(消息)队列;
    2. 鼠标按下时,往这个队列中增加一个点击事件(消息);
    3. 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
    4. 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数;

  事件驱动编程是一种网络编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。另外两种常见的编程范式是(单线程)同步以及多线程编程。

  让我们用例子来比较和对比一下单线程、多线程以及事件驱动编程模型。下图展示了随着时间的推移,这三种模式下程序所做的工作。这个程序有3个任务需要完成,每个任务都在等待I/O操作时阻塞自身。阻塞在I/O操作上所花费的时间已经用灰色框标示出来了。

  在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。

  在多线程版本中,这3个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。

  在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。

当我们面对如下的环境时,事件驱动模型通常是一个好的选择:

  1. 程序中有许多任务,而且…
  2. 任务之间高度独立(因此它们不需要互相通信,或者等待彼此)而且…
  3. 在等待事件到来时,某些任务会阻塞。

  当应用程序需要在任务间共享可变的数据时,这也是一个不错的选择,因为这里不需要采用同步处理。

  网络应用程序通常都有上述这些特点,这使得它们能够很好的契合事件驱动编程模型。

 

4 select/poll/epoll的区别及其Python示例

Python中有一个select模块,其中提供了:select、poll、epoll三个方法(根据系统的不同,select模块提供了不同的方法,在linux中select模块提供了全部三种方法),分别调用系统的 select,poll,epoll从而实现IO多路复用。

    注意:网络操作、文件操作、终端操作等均属于IO操作,对于windows只支持Socket操作,其他系统支持其他IO操作,但是无法检测普通文件操作,自动检测文件是否已经变化。普通文件操作,所有系统都是完成不了的,普通文件是属于I/O操作!但是对于python来说文件变更,python是监控不了的,所以我们能用的只有是“终端的输入输出,Socket的输入输出”

4.1 select/poll/epoll的区别

  首先前文已述I/O多路复用的本质就是用select/poll/epoll,去监听多个socket对象,如果其中的socket对象有变化,只要有变化,用户进程就知道了。

  select是不断轮询去监听的socket,socket个数有限制,一般为1024个;

  poll还是采用轮询方式监听,只不过没有个数限制;

  epoll并不是采用轮询方式去监听了,而是当socket有变化时通过回调的方式主动告知用户进程。

 

4.2 Python select

Python的select()方法直接调用操作系统的IO接口,它监控sockets,open files, and pipes(所有带fileno()方法的文件句柄)何时变成readable 和writeable, 或者通信错误,select()使得同时监控多个连接变的简单,并且这比写一个长循环来等待和监控多客户端连接要高效,因为select直接通过操作系统提供的C的网络接口进行操作,而不是通过Python的解释器。

句柄列表11, 句柄列表22, 句柄列表33 = select.select(句柄列表1, 句柄列表2, 句柄列表3, 超时时间)
 
参数: 可接受四个参数(前三个必须)
返回值:三个列表

rlist, wlist, elist = select.select(inputs,[],[],1)
1、第一个参数,监听的句柄序列,当有任一句柄变化时,select就能捕获到并返回赋值给rlist; 2、如果第二参数有值,即只要不是空列表,select就能感知,wlist就能获取第二个参数的值; 3、对于第三个参数,在select内部会检测列表中的描述符在底层执行过程中是否发生异常,如果发生异常,则把发生异常的句柄赋值给elist,
一般第三个参数和第一个参数相同 4、第四个参数是设置阻塞时间,如1秒(这个如果不写,select会阻塞住,直到监听的描述符发生变化才继续往下执行)
 
  
#rlist -- wait until ready for reading  #等待直到有读的操作

#wlist -- wait until ready for writing #等待直到有写的操作
#xlist -- wait for an ``exceptional condition'' #等待一个错误的情况

select方法用来监视文件句柄,如果句柄发生变化,则获取该句柄。
1、当 参数1 序列中的句柄发生可读时(accetp和read),则获取发生变化的句柄并添加到 返回值1 序列中
2、当 参数2 序列中含有句柄时,则将该序列中所有的句柄添加到 返回值2 序列中
3、当 参数3 序列中的句柄发生错误时,则将该发生错误的句柄添加到 返回值3 序列中
4、当 超时时间未设置,则select会一直阻塞,直到监听的句柄发生变化
5、当 超时时间=1时,那么如果监听的句柄均无任何变化,则select会阻塞1秒,之后返回三个空列表,如果监听的句柄有变化,则直接执行。

由于select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。这里需要指出的是,客户端的一个connect()操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的connect()行为。

 

 1 #!/usr/bin/env python
 2 # -*- coding:utf-8 -*-
 3 
 4 import select
 5 import sys
 6 
 7 while True:
 8     readable, writeable, error = select.select([sys.stdin,],[],[],1)
 9     '''select.select([sys.stdin,],[],[],1)用到I/O多路复用,第一个参数是列表,sys.stdin是系统标准输入的文件描述符, 就是打开标准输入终端返回的文件描述符,一旦终端有输入操作,select就感知sys.stdin描述符的变化,那么会将变化的描述符sys.stdin添加到返回值readable中;如果终端一直没有输入,那么readable他就是一个空列表
10     '''
11     if sys.stdin in readable:
12         print 'select get stdin',sys.stdin.readline()
13 '''
14 注:
15 1、[sys.stdin,]以后不管是列表还是元组,在最后一个元素的后面建议增加一个逗号,(1,) | (1) 这两个有区别吗?是不是第二个更像方法的调用或者函数的调用,加个,是不是更容易分清楚。还有就是在以后写django的配置文件的时候,他是必须要加的。
16 2、select的第一个参数就是要监听的文件句柄,只要监听的文件句柄有变化,那么就会将其加入到返回值readable列表中。
17 3、select最后一个参数1是超时时间,当执行select时,如果监听的文件句柄没有变化,则会阻塞1秒,然后向下继续执行;默认timeout=None,就是会一直阻塞,直到感知到变化
18 '''
19 '''
20 when runing the program get error :
21 Traceback (most recent call last):
22   File "E:/study/GitHub/homework/tianshuai/share_3_select_socket.py", line 8, in <module>
23     readable, writeable, error = select.select([sys.stdin,],[],[],1)
24 select.error: (10093, 'Either the application has not called WSAStartup, or WSAStartup failed')
25 
26 when windows only use select socket !!!!!
27 '''
实例1:利用select监听终端输入
#/usr/bin/env python
#-*- coding:utf-8 -*-

import time
import socket
import select

#生成socket对象
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
#绑定IP和端口
sk.bind(('127.0.0.1',6666))
#监听,并设置最大连接数为5
sk.listen(5)
#设置setblocking为False,即非阻塞模式,accept将不在阻塞,如果没有收到请求就会报错
sk.setblocking(False) 

while True:
    rlist, wlist, elist = select.select([sk,],[],[],2)  #监听第一个列表的文件描述符,如果其中有文件描述符发生改变,则捕获并放到rlist中
    for r in rlist:   #如果rlist非空将执行,否则不执行
        conn,addr = r.accept()   #建立连接,生成客户端的socket对象以及IP地址和端口号
        print addr
实例2:利用select监听浏览器访问

执行程序,在浏览器中输入地址:127.0.0.1:6666  

执行结果如下:

('127.0.0.1', 42510)


#/usr/bin/env python
#-*- coding:utf-8 -*-

import time
import socket
import select

#生成socket对象
sk1 = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
#绑定IP和端口
sk1.bind(('127.0.0.1',6666))
#监听并设置最大连接数
sk1.listen(5)
sk1.setblocking(False) #设置setblocking为False,accept将不在阻塞,如果没有收到请求就会报错,即非阻塞模式

sk2 = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk2.bind(('127.0.0.1',7777))
sk2.listen(5)
sk2.setblocking(False) #设置非阻塞模式

while True:
    rlist, wlist, elist = select.select([sk1,sk2,],[],[],2)  #监听第一个列表的文件描述符,如果其中有文件描述符发生改变,则捕获并放到rlist中
    for r in readable_list:  
        conn,address = r.accept()
        print address

实例3:利用select监听多端口
实例3:利用select监听多端口

执行程序,在浏览器中分别输入地址:127.0.0.1:6666  127.0.0.1:7777

执行结果如下:

('127.0.0.1', 54509)
('127.0.0.1', 53458)
 
#/usr/bin/env python
#-*- coding:utf-8 -*-

import time
import socket
import select

sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.bind(('127.0.0.1',6666))
sk.listen(5)
sk.setblocking(False) #非阻塞
inputs = [sk,] #构造select第一个参数
#原因:看上例conn是客户端对象,客户是一直连接着呢,连接的时候状态变了,连接上之后,还是服务端的socket有关吗?是不是的把他改为动态的?

while True:
    rlist, wlist, elist = select.select(inputs,[],[],1)  #把第一个参数设为列表动态的添加
    time.sleep(2) #测试使用
    print "inputs list :",inputs     #打印inputs列表,查看执行变化
    print "file descriptor :",readable_list #打印rlist ,查看执行变化

    for r in rlist:
        #当客户端第1次连接服务端时
        if r == sk:  
            conn,address = r.accept()
            inputs.append(conn)
            print address
        else:
        # 当客户端连接上服务端之后,再次发送数据时
            client_data = r.recv(1024)
            r.sendall(client_data)

实例4:利用select实现伪同时处理多个Socket客户端请求—服务端
实例4:利用select实现伪同时处理多个Socket客户端请求—服务端
 
 
#!/usr/bin/env python
#-*- coding:utf-8 -*-

import socket

client = socket.socket()
client.connect(('127.0.0.1',6666))
client.settimeout(5)

while True:
    client_input = raw_input('please input message:').strip()
    client.sendall(client_input)
    server_data = client.recv(1024)
    print server_data
实例4:利用select实现伪同时处理多个Socket客户端请求—客户端
#优化点:当客户端断开连接时,从inputs列表中删除其文件描述符

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import socket
import select

sk1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk1.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk1.bind(('127.0.0.1',8002))
sk1.listen(5)
sk1.setblocking(0)

inputs = [sk1,]

while True:
    readable_list, writeable_list, error_list = select.select(inputs, [], inputs, 1)
    for r in readable_list:
        # 当客户端第一次连接服务端时
        if sk1 == r:
            print 'accept'
            request, address = r.accept()
            request.setblocking(0)
            inputs.append(request)
        # 当客户端连接上服务端之后,再次发送数据时
        else:
            received = r.recv(1024)
            # 当正常接收客户端发送的数据时
            if received:
                print 'received data:', received
            # 当客户端关闭程序时
            else:
                inputs.remove(r)

sk1.close()
实例5:实例4服务端的优化

此处的Socket服务端相比与原生的Socket,他支持当某一个请求不再发送数据时,服务器端不会等待而是可以去处理其他请求的数据。但是,如果每个请求的耗时比较长时,select版本的服务器端也无法完成同时操作。


参考:
https://www.cnblogs.com/maociping/p/5129858.html
https://www.cnblogs.com/zingp/p/6863170.html

 

转载于:https://www.cnblogs.com/strive-man/p/8697595.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值