select,poll和epoll详解

select,poll和epoll详解

Linux系统在访问设备的时候,存在以下几种IO模型:

  • Blocking IO Model,阻塞IO模型;
  • Nonblocking I/O Model,非阻塞IO模型;
  • I/O Multiplexing Model,IO多路复用模型;
  • Signal Driven I/O Model,信号驱动IO模型;
  • Asynchronous I/O Model,异步IO模型;

今天我们来分析下IO多路复用机制,在Linux中是通过select/poll/epoll机制来实现的。

请先阅读 Epoll的本质

1.IO复用

为了解决大量客户端访问的问题,引入IO复用技术:一个进程可以同时对多个客户请求进行服务,复用一个进程对多个IO进行服务。IO读写的数据多数情况下未准备好,需要通过一个函数监听这些数据状态,一旦有数据可以读写就触发服务。elect,poll,epoll都是IO多路复用的机制,监视多个描述符,一旦某个描述符就绪,通知程序进行操作。

2.select

2.1 select实现原理

select()的机制中提供一fd_set的数据结构,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。

2.1.1 select函数
#include <sys/select.h>
#include <unistd.h>
 
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
	参数:
        - nfds: 委托内核检测的最大文件描述符的值 + 1
        - readfds: select监视的可读文件句柄集合
        	- 传入传出的参数
        	- 委托内核检测读缓冲区是不是可以读数据
        - writefds: select监视的可写文件句柄集合
        	- 传入传出的参数
        	- 委托内核检测写缓冲区是不是还可以写数据(不满就可以写)
        - exceptfds: select监视的异常文件句柄集合 
            - 传入传出的参数
            - 委托内核检测哪些文件描述符出现了异常
       	- timeout:本次select的超时结束时间
			struct timeval {
			    long    tv_sec;         /* 秒 */
			    long    tv_usec;        /* 毫秒 */
			};
			- NULL: 永久阻塞, 直到检测到了文件描述符有变化
			- tv_sec = 0, tv_usec = 0, 不阻塞
			- tv_sec > 0 || tv_usec > 0, 阻塞对应的时间长度
	返回值:
		-1: 失败
		>0: 返回已准备好的文件描述符数
		0:超时

用户调用select会进入内核空间,并且调用 sys_select() 内核函数,主要完成以下工作。

  • 设置设备驱动回调函数指针(select需要设备驱动程序支持,驱动程序实现fops内的poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行)。
  • select循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。
  • 唤醒该进程的过程通常是在所监测文件的设备驱动内实现的,驱动程序维护了针对自身资源读写的等待队列。当设备驱动发现自身资源变为可读写并且有进程睡眠在该资源的等待队列上时,就会唤醒这个资源等待队列上的进程。

下图是select的调用过程:
在这里插入图片描述

  • 将需要监控的文件描述符集合fd_set拷贝到内核;
  • 用__pollwait初始化文件设备驱动程序的_qproc函数指针(__pollwait函数在下图介绍)
  • 遍历fd_set集合中的文件描述符,调用对应poll函数,通过返回mask判断文件描述符是否可读可写等,如条件满足,则将读fe_set集合对应文件描述符置位,并将需返回的描述符个数加1,如条件不满足,则调用poll_wait函数将调用select进程加入设备等待队列中与设置唤醒回调函数
  • 如所有条件都不满足,则调用poll_schedule_timeout让当前进程进入睡眠。超时、检测的文件描述符满足条件、有信号要处理这三种情况将唤醒进程。
  • 最终返回符合条件的文件描述符个数(retval)

下图是__pollwait函数和进程唤醒函数调用过程
在这里插入图片描述

  • __pollwait完成的工作是创建创建一个poll_table_entry并加入到struct poll_wqueues,用pollwake初始化唤醒函数指针 ,将当前进程加入设备驱动等待队列中。(poll_table_entry包含设备驱动程序中的struct wait_queue_entry_t,其中包含唤醒函数指针func)
  • 当设备驱动发现自身资源变为可读写并且有进程睡眠在该资源的等待队列上时,就会唤醒这个资源等待队列上的进程pollwake
    网络截图
2.1.2 相关操作
FD_ZERO(fd_set *)将某一个集合清空,每次select前都需要将集合清空

FD_SET(int, fd_set *)将一个给定的文件描述符加入到集合之中

FD_CLR(int, fd_set *)从集合中删除指定的文件描述符。

FD_ISSET(int, fd_set *)检查集合中指定的文件描述符是否准备好(可读或可写)

2.2 select使用

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/select.h>

int main(int argc, char **argv)
{
	//创建监听套接字
	int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if(-1 == lfd) {
		perror("socket");
		exit(0);
	}
	//绑定IP,PORT
	struct sockaddr_in addr;
	addr.sin_port = htons(12000);
	addr.sin_family = AF_INET;
	addr.sin_addr.s_addr = INADDR_ANY;
	if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
		perror("bind");
		exit(0);
	}
	//监听
	if(-1 == linsten(lfd, 64)) {
		perror("listen");
		exit(0);
	}
	
	//select
	fd_set rdset;
	int nready = 0, fdsize = 0;
	int buff[1024] = {0};
	buff[fdsize++] = lfd;
	while(1) {
		FD_ZERO(&rdset); 
		for(int i = 0; i < fdsize ; ++i) {
			FD_SET(buff[i], &rdset);
		}
		if( (0 > fdsize) || (1024 < fdsize)) {
			break;
		}
		//因为Linux中分配文件描述符时是从当前未被分配的最小文件描述符来分配的,
		//所以在select()函数的第一个参数只需要是buff中的最后一个文件描述符+1,
		//即可完成对加入到rset中所有文件描述符的监听
		nready = select(buff[fdsize - 1] + 1, &rdset, NULL, NULL, NULL);
		if(0 == nready) {
			//超时
			continue;
		}
		else if(-1 == nready){ 
			//失败
			Error(errno);
			for(int i = 1; i < fdsize ; ++i) {
				close(buff[i]);
			}
			break;
		}
		else {
			//通信
			for(int i = 0; i < fdsize; ++i) {
				if(FD_ISSET(buff[i], &rdset)) {
					if(lfd == buff[i]) {
						struct sockaddr_in caddr;
						int nlen = sizeof(caddr);
						int cfd = accept(lfd, (struct sockaddr*)&caddr, &nlen);
						if(-1 == cfd) {
							perror("accept");
							exit(0);
						}
						//新连接加入buff
						if(1024 <= fdsize) {
							printf("已经达到最大检测数(%d)。", fdsize);
						}
						else{
							buff[fdsize++] = cfd;
						}
					}
					else {
						char rbuff[1024] = {0};
						int ret = read(buff[i], rbuff, sizeof(rbuff));
						if(-1 == ret) {
							perror("read");
							exit(0);
						}
						else if(0 == ret) {
							printf("client disconnect.......");
							close(buff[i]);
							for(int j = i--; j < fdsize -1; ++j) {
								buff[j] = buff[j + 1];
							}
							buff[--fdsize] = 0;
						}
						else {
							write(buff[i], rbuff, strlen(rbuff)+1);
						}
					}
				}
			}
		}
	}
	close(lfd);
	return 0;
}

3.poll

由于select与poll本质上基本类似,其中select是由BSD UNIX引入,poll由SystemV引入。所以不在介绍poll的实现原理。

#include <poll.h>
struct pollfd {
 	int   fd;         /* 委托内核检测的文件描述符 */
 	short events;     /* 委托内核检测文件描述符的什么事件 */
 	short revents;    /* 文件描述符实际发生的事件 */
};
例子:
  struct pollfd myfd;
  myfd.fd = 5;
  myfd.events = POLLIN | POLLOUT;
  
 
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
	参数:
		- fds: 这是一个struct pollfd数组, 这是一个要检测的文件描述符的集合
		- nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1
        - timeout: 阻塞时长
        	0: 不阻塞
        	-1: 阻塞, 检测的fd有变化解除阻塞
        	>0: 阻塞时长
      返回值:
		-1: 失败
		>0(n): 检测的集合中有n个文件描述符发送的变化 
		0:超时

在这里插入图片描述

3.1 poll使用

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <poll.h>

int main(int argc, char **argv)
{
	//创建监听套接字
	int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if(-1 == lfd) {
		perror("socket");
		exit(0);
	}
	//绑定IP,PORT
	struct sockaddr_in addr;
	addr.sin_port = htons(12000);
	addr.sin_family = AF_INET;
	addr.sin_addr.s_addr = INADDR_ANY;
	if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
		perror("bind");
		exit(0);
	}
	//监听
	if(-1 == linsten(lfd, 64)) {
		perror("listen");
		exit(0);
	}

	//poll
	int nready =0 , fdsize = 0;
	struct pollfd events[64];
	memset(events, 0, sizeof(events) * 64);
	events[fdsize].fd = lfd;
	events[fdsize++].events = POLLIN;
	
	while(1) {
		if(0 >= fdsize){
			break;
		}
		nready = poll(events, 64, -1);
		if(0 == nready) {
			//超时
			continue;
		}
		else if(-1 == nready){ 
			//失败
			Error(errno);
			for(int i = 1; i < fdsize ; ++i) {
				close(events[fdsize].fd);
			}
			break;
		}
		else {
			//通信
			for(int i = 0; i < fdsize; ++i) {
				if(events[i].revents & (POLLIN | POLLHUP | POLLERR)) {
					if(events[i].fd = lfd) {
						//新连接到来
						struct sockaddr_in caddr;
						int nlen = sizeof(caddr);
						int cfd = accept(lfd, (struct sockaddr*)&caddr, &nlen);
						if(-1 == cfd) {
							perror("accept");
							exit(0);
						}
						//新连接加入buff
						if(64 <= fdsize) {
							printf("已经达到最大检测数(%d)。", fdsize);
						}
						else{
							events[fdsize].fd = cfd;
							events[fdsize++].events = POLLIN;
						}
					}
					else {
						char rbuff[1024] = {0};
						int ret = read(events[i].fd, rbuff, sizeof(rbuff));
						if(-1 == ret) {
							perror("read");
							exit(0);
						}
						else if(0 == ret) {
							printf("client disconnect.......");
							close(events[i].fd);
							for(int j = i--; j < fdsize -1; ++j) {
								events[j] = events[j + 1];
							}
							memset(&events[--fdsize], 0, sizeof(events[--fdsize]));
						}
						else {
							write(events[i].fd, rbuff, strlen(rbuff)+1);
						}
					}
				}
			}
		}
	}
	close(lfd);
	return 0;
}

4.epoll

4.1 epoll的实现原理

4.1.1 epoll的创建

要使用 epoll 首先需要调用 epoll_create() 函数创建一个 epoll 的句柄,epoll_create() 函数定义如下:

#include <sys/epoll.h>
// 创建一棵红黑树
int epoll_create(int size);
  	参数: 
  		size: 没意义(参数 size 是由于历史原因遗留下来的,现在不起作用)
  	返回值;
  		>0: epoll句柄
  		<=0: 失败

用户调用epoll_create会进入内核空间,并且调用 sys_epoll_create() 内核函数来创建 epoll 句柄。
sys_epoll_create() 主要完成两件事情:

  • 创建并初始化一个 eventpoll 对象;
  • 把 eventpoll 对象映射到一个文件句柄,并返回这个文件句柄。
struct eventpoll {
    ...
    //等待队列,当调用 epoll_wait 时会把进程添加到 eventpoll 对象的 wq 等待队列中
    wait_queue_head_t wq;  
    ...
    //保存已经就绪的事件列表,通过epoll_wait返回给用户
    struct list_head rdllist; 
    //红黑树的根节点,使用红黑树来管理所有被监听的事件,这颗树中存储着所有添加到epoll中的需要监控的事件
    struct rb_root rbr; 
    ...
};
//红黑树被监听的事件通过epitem对象管理
struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}

下图展示了eventpoll与被监听事件的关系:
在这里插入图片描述

4.1.2 epoll添加事件

通过调用 epoll_ctl() 函数可以向 epoll 添加要监听的事件,其原型如下:

#include <sys/epoll.h>
 
typedef union epoll_data {
  	void        *ptr;	// 复杂
  	int          fd;	// 简单    一般就使用这个就好,文件描述符传入即可;
  	uint64_t     u64;
} epoll_data_t;
  
struct epoll_event {
  	uint32_t     events;      /* Epoll 事件 */
  	epoll_data_t data;        /* 上面这个共用体中,一般使用fd */
};
Epoll检测的事件:
 - EPOLLIN :表示对应的文件句柄可以读(包括对端SOCKET正常关闭);
 - EPOLLOUT:表示对应的文件句柄可以写;
 - EPOLLPRI:表示对应的文件句柄有紧急的数据可读;
 - EPOLLERR:表示对应的文件句柄发生错误;
 - EPOLLHUP:表示对应的文件句柄被挂断;
 - EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
 - EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

LT(level triggered)是缺省的工作方式,并且同时支持block(阻塞)和no-block (非阻塞)socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后可以对这个就绪的fd进行IO操作。只要有数据,内核会一直通知。
ET(edge-triggered)是高速工作方式,只支持no-block(非阻塞) socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll通知。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高`。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
  
// 对epoll树进行管理: 添加节点, 删除节点, 修改已有的节点属性
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  	参数:
  		- epfd: epoll_create的返回的epoll句柄
  		- op: 要进行什么样的操作
  			EPOLL_CTL_ADD: 注册新节点, 添加到红黑树上
  			EPOLL_CTL_MOD: 修改检测的文件描述符的属性
  			EPOLL_CTL_DEL: 从红黑树上删除节点
  		- fd: 要检测的文件描述符的值
  		- event: 检测文件描述符的什么事件

epoll_ctl() 函数会调用 sys_epoll_ctl() 内核函数,sys_epoll_ctl() 主要完成以下事情:

  • 根据op完成相应操作,下面讲解的操作是添加socket事件;
  • 创建被监听socket事件的epitem对象;
  • 调用tcp_poll接口,将epitem对象添加到socket的等待队列中,并且设置唤醒函数ep_poll_callback(),当socket状态发生变化时,会触发调用ep_poll_callback() 函数。(ep_poll_callback() 函数的主要工作是把就绪的epitem添加到 eventpoll 对象的rdllist中,然后调用wake_up唤醒调用 epoll_wait() 被阻塞的进程)
  • 将epitem添加入epfd对应的红黑树中进行管理
4.1.3 epoll等待事件

把被监听的文件句柄添加到epoll后,就可以通过调用 epoll_wait() 等待被监听的文件状态发生改变。epoll_wait() 调用会阻塞当前进程,当被监听的文件状态发生改变时,epoll_wait() 调用便会返回。

#include <sys/epoll.h>
struct epoll_event events[1000];
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  	参数:
  		- epfd: epoll_create的返回的epoll句柄
  		- events: 从eventpoll rdllist双链表中拷贝出的epitem对象信息
  		- maxevents: 第二个参数结构体数组的大小
  		- timeout: 阻塞时间
  			- 0: 不阻塞
  			- -1: 一直阻塞, 知道检测的fd有状态变化, 解除阻塞
  			- >0: 阻塞的时长(毫秒)
    返回值:
  		- 成功: 有多少个文件描述符状态发生了变化 > 0
        - 失败: -1

epoll_wait函数会调用sys_epoll_wait()内核函数,sys_epoll_wait() 主要完成以下事情:

  • 判断被监听的文件eventpoll对象的rdllist双链表中是否有就绪的epitem对象,如果有,调用 ep_send_events() 函数把就绪epitem复制到 events 参数中,然后返回就绪文件个数。
  • 如果没有就把当前进程添加到eventpoll的等待队列中,并且进入睡眠
  • 进程会一直睡眠直到有以下几种情况发生:
    1. 被监听的文件描述符状态变化
    2. 设置了超时时间并且超时了
    3. 接收到信号
  • 如果有,调用 ep_send_events() 函数把就绪epitem复制到 events 参数中,然后返回就绪文件个数。
4.1.4 调用总结

在这里插入图片描述

  • 通过调用 epoll_create() 函数创建并初始化一个 eventpoll 对象。
  • 通过调用 epoll_ctl()函数把被监听的文件句柄 (如socket句柄) 封装成 epitem 对象并且添加到 eventpoll 对象的红黑树中进行管理。
  • 通过调用 epoll_wait() 函数等待被监听的文件状态发生改变。
  • 当被监听的文件状态发生改变时(如socket接收到数据),会把文件句柄对应 epitem 对象添加到 eventpoll 对象的就绪队列 rdllist 中。并且把就绪队列的文件列表复制到 epoll_wait() 函数的 events 参数中。
  • 唤醒调用epoll_wait() 函数被阻塞(睡眠)的进程。

4.2 epoll使用

epoll 服务器端代码模板示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

int main(int argc, char **agrv)
{
	//创建监听套接字
	int lfd = socket(AF_INET, SOCK_STREAM, 0);
	if(-1 == lfd) {
		perror("socket");
		exit(0);
	}
	//绑定IP,PORT
	struct sockaddr_in addr;
	addr.sin_port = htons(12000);
	addr.sin_family = AF_INET;
	addr.sin_addr.s_addr = INADDR_ANY;
	if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
		perror("bind");
		exit(0);
	}
	//监听
	if(-1 == linsten(lfd, 64)) {
		perror("listen");
		exit(0);
	}
	
	//创建epoll
	int epfd = epoll_create(1024);
	if(0 >= epfd) {
		perror("epoll_create");
		exit(0);
	}
	//将socket 监听文件描述符添加到epoll中
	struct epoll_event ev;
	ev.events = EPOLLIN; //读
	ev.data.fd = lfd;
	if(0 > epoll_ctl(epfd, EPOLL_ATL_ADD, lfd, &ev)) {
		perror("epoll_ctl");
		exit(0);
	}
	//开始监听网络连接
	struct epoll_event events[1024];
	while(1) {
		int nready = epoll_wait(epfd, events, sizeof(events), -1);
		if(0 > nready) {
			perror("epoll_wait");
			exit(0);
		}
		//处理连接
		for(int i = 0; i < nready; ++i) {
			int fd = events[i].data.fd;
			if(lfd == fd) { 
				//新连接到来
				struct sockaddr_in caddr; 
				int nlen = sizeof(caddr);
				int cfd = accept(lfd, (struct sockaddr *)&caddr, &nlen);
				if(-1 == cfd) {
					perror("accept");
					exit(0);
				}
				//若设置为边沿触发,则需设置fd属性为非阻塞
                //int flag = fcntl(connfd, F_GETFL);
                //flag |= O_NONBLOCK;
                //fcntl(connfd, F_SETFL, flag);
                //ev.events = EPOLLIN | EPOLLET;

				//将新的客户端连接加入epoll
				ev.events = EPOLLIN;
				ev.data.fd = cfd;
				if(0 > epoll_ctl(epfd, EPOLL_ATL_ADD, cfd, &ev)) {
					perror("epoll_ctl");
					exit(0);
				}
			}
			else {
				//与客户端通信
				if(events[i].events & EPOLLIN) {
					char buff[1024] = {0};
					int nread = read(fd, buff, sizeof(buff));
					if(0 == nread) {
						printf("client disconnect.......");
						close(fd);
						epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
					}
					else if(-1 == nread) {
						perror("read");
						exit(0);
					}
					else {
						//发送数据
						write(fd, buff, strlen(buf)+1);
					}
				}
			}
		}
	}
	close(lfd);
	close(epfd);
	return 0;
}

5.select,poll,epoll区别

  • 支持一个进程所能打开的最大连接数:
    select:单个进程所能打开的最大连接数由FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264)。
    poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
    epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
  • 文件描述符剧增后带来的IO效率问题
    select/poll:因为每次调用时都会对监控的所有文件描述符进行线性遍历,所以随着的文件描述符的增加会造成遍历速度慢的“线性下降性能”问题。
    epoll:因为epoll内核中实现是根据每个文件描述符上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
  • 消息传递方式
    select/poll:用户和内核之间采用数据拷贝的方式传递消息
    epoll:epoll通过内核和用户空间共享一块内存实现消息传递。

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

  • 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

  • select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

  • 8
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值