Linux高性能服务器编程学习记录——九、I/O复用

18 篇文章 0 订阅
16 篇文章 0 订阅

在不使用I/O复用的技术下,如果要同时监听多个fd上的事件得使用多个线程(或进程,下同),每个线程单独负责一个fd上事件。而I/O复用技术可以同时监听多个fd,即在一个线程里监听多个fd的事件。另外I/O本身是阻塞的,在它返回后,如果要实现并发,还是得使用多进程或多线程的变成手段。
Linux下实现的I/O复用的系统调用主要有select、poll和epoll。

1、select系统调用

API

select系统调用的用途是在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。

#include <sys/select.h>
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);

1)nfds参数指定被监听的文件描述符总数。
2)readfds、writefds、exceptfds分别是可读、可写和异常事件对应的fd集合。fd结构体如下

#include <typesizes.h>
#define __FD_SETSIZE 1024

#include <sys/select.h>
typedef long int __fd_mask;
#undef	__NFDBITS
/* It's easier to assume 8-bit bytes than to get CHAR_BIT.  */
#define __NFDBITS	(8 * (int) sizeof (__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;

/* Access macros for `fd_set'.  */
#define	FD_SET(fd, fdsetp)		__FD_SET (fd, fdsetp)	//设置fdsetp的位fd
#define	FD_CLR(fd, fdsetp)		__FD_CLR (fd, fdsetp)	//清除fdsetp的位fd
#define	FD_ISSET(fd, fdsetp)	__FD_ISSET (fd, fdsetp)	//测试fdsetp的位fd是否被设置
#define	FD_ZERO(fdsetp)			__FD_ZERO (fdsetp) 		//清除所有位

可以看到,fd_set结构体包含一个整型数组,数组的每个元素的每一位标记一个fd,fd_set能容纳的fd数量由__FD_SETSIZE指定,这就限制了select能同时处理的fd的总量。可以通过上面定义的宏来访问fd_set结构体中的位。
3)timeou参数用来设置select函数的超时时间,timeval结构体如下

struct timeval
{
	long tv_sec; 	//秒数
	long tv_usec; 	//微秒数
};

如果给timeout变量的tv_sec和tv_usec都传递0,则select立即返回,传递NULL,select就一直阻塞,直到有fd就绪。
select系统调用成功返回就绪的fd总数数量,超时返回0,失败返回-1,并设置errno。

fd的就绪条件

在网络编程中,下列情况下socket可读:

  • socket内核接收缓冲区中的字节数大于等于低水位标记
  • socket通信对方关闭连接,此时socket的读操作返回0
  • 正在监听的socket上有新的连接请求
  • socket上有未处理的错误,此时可以使用getsockopt来读取和清除该错误

下列情况下可写

  • socket内核发送缓存区中的可用字节数大于等于低水位标记
  • socket的写操作被关闭。对写操作别关闭的socket执行写操作将触发一个SIGPIPE信号
  • socket使用非阻塞connect连接成功或失败(超时)后
  • socket上有未处理的错误,此时可以使用getsockopt来读取和清除该错误

网络程序中,select能处理的异常情况只有一种,socket上接收到带外数据。

select的缺点
  • 可同时处理的fd总量有限制,默认1024
  • 每次调用都需要重新传入几个fd集合(涉及到用户态到内核态的拷贝)
  • 返回后需要遍历整个fd集合来找到就绪的fd

2、poll系统调用

poll与select类似,也是在指定时间内轮询一定数量的fd,以测试其中是否有就绪者。

#include <poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

struct pollfd
{
	int fd;
	short events; 		//注册的事件
	short revents; 		//实际发生的事件,由内核填充
}
l
typedef unsigned long int nfds_t;

1)fds是pollfd类型的数组,pollfd中events和revents是一系列事件的按位或,events由poll的调用用户填充,revents在事件发生后由内核填充。
2)nfds参数指定被监听事件集合fds的大小
3)timeout为-1时,poll一直阻塞直到某fd就绪,为0立即返回。
poll系统调用的返回值含义与select相同。
与select相比,poll只解决了select的第一个缺点,后面两个依然存在。

3、epoll系列系统调用

select和poll都是一个函数了事,而epoll有三个。epoll会把用户关心的fd上的事件放到内核中的一个事件表里,从而无须像select和poll那样每次调用都要重复传入fd集合和事件集。

三个系统调回用
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctrl(int epfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

struct epoll_event
{
	__uint32_t events; 	//epoll事件
	epoll_data_t data; 	//用户数据
};
typedef union epoll_data
{
	void* ptr;
	int fd;
	uint32_t u32;
	uint64_t u64;
} epoll_data_t;

epoll_create创建一个内核事件表,成功返回表示这个事件表的fd,参数size现在并不起作用,只是给内核一个提示,告诉他事件表需要多大。
epoll_ctrl用户来操作上面创建的事件表,成功返回0,失败返回-1并设置errno。fd是要操作的文件描述符,op是操作类型,有以下3种:

  • EPOLL_CTL_ADD 往事件表种注册fd上的事件
  • EPOLL_CTL_MOD 修改fd上的注册事件
  • EPOLL_CTL_DEL 删除fd上的注册事件

event参数指定事件。
epoll_wait在一段超时时间内等待一组fd上的事件。成功返回就绪的fd的个数,失败返回-1,并设置errno。maxevents指定最多监听多少事件,必须大于0。events中保存已经由内核准备好的就绪的事件列表。这个列表里的都是已就绪的事件,而不像select和poll那样。
可以看出epoll将select的三个缺点都解决了。

LT和ET模式

epoll对文件描述符的操作有两种模式:LT(Level Trigger)水平触发和ET(Edge Trigger)边沿触发。
LT模式下的epoll,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理,当下次调用epoll_wait时,epoll_wait还会再次通知,直到事件被处理。
ET模式下的epoll则不然,它在检测到其上有事件发生并将此事件通知应用程序后,不管应用程序是不是立即处理,在下次调用epoll_wait时都将不会再通知此事件,除非有新的事件到来。

EPOLLONESHOT事件

在多线程编程中,如果一个线程在读取完某个socket上的数据后开始处理数据,而此时该socket上又触发新的事件,此时系统选择另一个线程来处理,这样同时有两个线程处理这个socket,这是不被期望的。
在注册了EPOLLONESHOT事件的fd,操作系统最大触发其上注册的一个可读、可写或异常事件,且只触发一次,除非使用epoll_ctl重置该fd上的EPOLLONESHOT事件。

4、I/O复用的高级应用

非阻塞connect(使用select)
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <assert.h>
#include <time.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <string.h>
#include <unistd.h>

#define BUFFER_SIZE	1023

int setnonbolcking(int fd)
{
	int old = fcntl(fd, F_GETFL);
	int newo = old | O_NONBLOCK;
	fcntl(fd, F_SETFL, newo);
	return old;
}

//超时连接函数,成功返回已经处于连接的socket,失败返回-1
int unblock_connect(const char* ip, int port, int time)
{
	int ret = 0;
	struct sockaddr_in address;
	bzero(&address, sizeof(address));
	address.sin_family = AF_INET;
	address.sin_port = htons(port);
	inet_pton(AF_INET, ip, &address.sin_addr);

	int sockfd = socket(PF_INET, SOCK_STREAM, 0);
	assert(sockfd > 0);
	int fdopt = setnonbolcking(sockfd);

	ret = connect(sockfd, (struct sockaddr*)&address, sizeof(address));
	if (ret == 0)
	{
		//连接成功 恢复sockfd的属性,立即返回
		printf("connnect with server immediately\n");
		fcntl(sockfd, F_SETFL, fdopt);
		return sockfd;
	}
	else if (errno != EINPROGRESS)
	{
		//连接失败
		printf("unlock connect not suppotr\n");
		return -1;
	}
	//正在连接中
	fd_set readfds;
	fd_set writefds;
	struct timeval timeout;

	//监听写事件 这是因为connect的man手册中讲到,使用非阻塞的connect时,可以通过监听sockfd的可写事件,当select、
	//poll等函数返回后,利用getsockopt来读取错误码,错误码为0表示连接建立成功,否则失败
	FD_ZERO(&readfds);
	FD_SET(sockfd, &writefds);

	timeout.tv_sec = time;
	timeout.tv_usec = 0;

	ret = select(sockfd + 1, NULL, &writefds, NULL, &timeout);
	if (ret <= 0)
	{
		//超时或出错
		printf("connection timeout\n");
		close(sockfd);
		return -1;
	}

	if (!FD_ISSET(sockfd, &writefds))
	{
		printf("no events on sockfd found\n");
		close(sockfd);
		return -1;
	}

	int error = 0;
	socklen_t length = sizeof(error);
	//调用getsockopt来获取并清除sockfd上的错误
	if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &length) < 0)
	{
		printf("get socket option faild\n");
		close(sockfd);
		return -1;
	}

	if (error != 0)
	{
		printf("connection faild after select with error:%d\n", error);
		close(sockfd);
		return -1;
	}

	//连接成功
	printf("connection ready after select with the socket:%d", sockfd);
	fcntl(sockfd, F_SETFL, fdopt);
	return sockfd;
}

int main(int argc, char* argv[])
{
	if (argc <= 2)
	{
		printf("usage: %s ip port\n", argv[0]);
		return 1;
	}

	int sockfd = unblock_connect(argv[1], atoi(argv[2]), 10);
	if (sockfd < 0)
	{
		return 1;
	}
	close(sockfd);
	return 0;
}
聊天室(poll)

client

#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>
#include <assert.h>

#define BUFFER_SIZE 64

int main(int argc, char* argv[])
{
	if (argc <=2)
	{
		printf("usage: %s ip port\n", argv[0]);
		return 1;
	}
	const char* ip = argv[1];
	int port = atoi(argv[2]);

	struct sockaddr_in server_address;
	bzero(&server_address, sizeof(server_address));
	server_address.sin_family = AF_INET;
	server_address.sin_port = htons(port);
	inet_pton(AF_INET, ip, &server_address.sin_addr);

	int sockfd = socket(PF_INET, SOCK_STREAM, 0);
	assert(sockfd >= 0);
	if (connect(sockfd, (struct sockaddr*)&server_address, sizeof(server_address)) < 0)
	{
		printf("connection failed\n");
		close(sockfd);
		return 1;
	}

	pollfd fds[2];
	/*
		struct pollfd
		{
			int fd;
			short events; //注册的事件
			short revents; //实际发生的事件,由内核填充
		};
	*/
	//注册文件描述符0(标准输入)和文件描述符sockfd上的可读事件
	fds[0].fd = 0;
	fds[0].events = POLLIN;
	fds[0].revents = 0;
	fds[1].fd = sockfd;
	fds[1].events = POLLIN | POLLRDHUP;
	fds[1].revents = 0;

	char read_buf[BUFFER_SIZE];
	int pipefd[2];
	int ret = pipe(pipefd);
	assert(ret != -1);

	while (1)
	{
		ret = poll(fds, 2, -1);
		if (ret < 0)
		{
			printf("pool failure\n");
			break;
		}
		
		if (fds[1].revents & POLLRDHUP)
		{
			//对方关闭了连接
			printf("server close connection\n");
			break;
		}
		else if (fds[1].revents & POLLIN)
		{
			//sockfd上有可读事件
			memset(read_buf, '\0', BUFFER_SIZE);
			recv(fds[1].fd, read_buf, BUFFER_SIZE - 1, 0);
			printf("receive msg: %s\n", read_buf);
		}

		if (fds[0].revents & POLLIN)
		{
			//客户输入
			//使用splice将用户输入的数据直接写到sockfd上(零拷贝)

			//将标准输入里的数据写入管道
			ret = splice(0, NULL, pipefd[1], NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MORE);
			//将管道里的数据写入sockfd
			ret = splice(pipefd[0], NULL, sockfd, NULL, 32768, SPLICE_F_MOVE | SPLICE_F_MORE);
		}
	}
	close(sockfd);
	return 0;
}

server

#define _GNU_SOURCE 1
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <poll.h>
#include <fcntl.h>
#include <assert.h>
#include <errno.h>

#define USER_LIMIT 5	//最大用户数
#define BUFFER_SIZE 64	//读缓冲区大小
#define FD_LIMIT 65535	//文件描述符数量限制

/*
	客户数据:客户端socket地址
	待写到客户端的数据的位置,即客户端写缓冲区的位置
	从客户端读入的数据
*/
struct client_data
{
	sockaddr_in address;
	char* write_buf;
	char buf[BUFFER_SIZE];
};

int setnonblocking(int fd)
{
	int oldopt = fcntl(fd, F_GETFL);
	int newopt = oldopt | O_NONBLOCK;
	fcntl(fd, F_SETFL, newopt);
	return oldopt;
}

int main(int argc, char* argv[])
{
	if (argc <= 2)
	{
		printf("usage: %s ip port\n", argv[0]);
		return 1;
	}

	const char* ip = argv[1];
	int port = atoi(argv[2]);

	struct sockaddr_in address;
	bzero(&address, sizeof(address));
	address.sin_family = AF_INET;
	address.sin_port = htons(port);
	inet_pton(AF_INET, ip, &address.sin_addr);

	int listenfd = socket(PF_INET, SOCK_STREAM, 0);
	assert(listenfd >= 0);
	int ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
	assert(ret != -1);
	ret = listen(listenfd, 5);
	printf("listen ret %d %d %s\n", ret, errno, strerror(errno));
	assert(ret != -1);

	/* 创建users数组,分配FD_LIMIT个client_data对象。可以预期,每个可能的socket连接都可以
	* 获得这样一个对象
	*/
	client_data* users = new client_data[FD_LIMIT];
	
	pollfd fds[USER_LIMIT+1];
	int user_counter = 0;
	for (int i = 0; i <= USER_LIMIT; ++i)
	{
		fds[0].fd = -1;
		fds[0].events = 0;
	}

	fds[0].fd = listenfd;
	fds[0].events = POLLIN | POLLERR;
	fds[0].revents = 0;

	while (1)
	{
		ret = poll(fds, user_counter + 1, -1);
		if (ret < 0)
		{
			printf("poll failure\n");
			break;
		}

		for(int i = 0; i < user_counter + 1; ++i)
		{
			//新连接
			if ((fds[i].fd == listenfd) && (fds[i].revents & POLLIN))
			{
				struct sockaddr_in client_address;
				socklen_t client_length = sizeof(client_address);
				int connfd = accept(fds[i].fd, (struct sockaddr*)&client_address, &client_length);
				if (connfd < 0)
				{
					printf("accept failure %d\n", errno);
					continue;
				}
				//可接受的连接已满
				if (user_counter >= USER_LIMIT)
				{
					const char* info = "too many users\n";
					printf("%s", info);
					send(connfd, info, strlen(info), 0);
					close(connfd);
					continue;
				}

				user_counter++;
				users[connfd].address = client_address;
				setnonblocking(connfd);
				fds[user_counter].fd = connfd;
				fds[user_counter].events = POLLIN | POLLRDHUP | POLLERR;
				fds[user_counter].revents = 0;

				//保存点分十进制ip地址
				char ipaddr[INET_ADDRSTRLEN];
				memset(ipaddr, '\0', INET_ADDRSTRLEN);
				printf("comes a new user %s:%d, now %d\n",
					inet_ntop(AF_INET, &client_address.sin_addr, ipaddr, sizeof(ipaddr)),
					ntohs(client_address.sin_port),
					user_counter);
			}
			else if (fds[i].revents & POLLERR)
			{
				//客户端连接出错
				printf("get an error from %d ", fds[i].fd);
				char errors[100];
				memset(errors, '\0', 100);
				socklen_t length = sizeof(errors);
				if (getsockopt(fds[i].fd, SOL_SOCKET, SO_ERROR, &errors, &length) < 0)
				{
					printf("\n getsocket option failed\n");
					continue;
				}
				printf("error %s\n", errors);
			}
			else if (fds[i].revents & POLLRDHUP)
			{
				//客户端关闭连接
				//作者这里的本意应该是将fds中最后一个的数据覆盖到当前数据,但是没明白
				//为什么要处理users 感觉应该将user[fds[i].fd]重置
				users[fds[i].fd] = users[fds[user_counter].fd];
				close(fds[i].fd);
				fds[i] = fds[user_counter];
				i--;
				user_counter--;
				printf("a client left\n");
			}
			else if (fds[i].revents & POLLIN)
			{
				//有客户发消息了
				int connfd = fds[i].fd;
				memset(users[connfd].buf, '\0', BUFFER_SIZE);
				//这里如果客户端发的消息长度超过BUFFER_SIZE-1,则在下一次循环中还会读取
				ret = recv(connfd, users[connfd].buf, BUFFER_SIZE - 1, 0);
				printf("get %d bytes msg from client %d, msg:%s\n", ret, connfd, users[connfd].buf);
				if (ret < 0)
				{
					//读操作出错
					if (errno != EAGAIN)
					{
						close(connfd);
						users[fds[i].fd] = users[fds[user_counter].fd];
						fds[i] = fds[user_counter];
						i--;
						user_counter--;
					}
				}
				else if (ret == 0)
				{
					//客户端关闭连接?
				}
				else
				{
					//正确接受到消息,通知其他socket准备写数据
					for (int j = 1; j <= user_counter; ++j)
					{
						if (fds[j].fd == connfd)
						{
							continue;
						}
						//注销读事件 添加写事件
						fds[j].events |= ~POLLIN;
						fds[j].events |= POLLOUT;
						//记录待写数据来源
						users[fds[j].fd].write_buf = users[connfd].buf;
					}
				}
			}
			else if (fds[i].revents & POLLOUT)
			{
				//可以写了
				int connfd = fds[i].fd;
				//安全判断
				if (!users[connfd].write_buf)
				{
					continue;
				}
				//向该客户端发送数据
				ret = send(connfd, users[connfd].write_buf, strlen(users[connfd].write_buf), 0);
				users[connfd].write_buf = NULL;
				//数据写完重新注册可读事件
				fds[i].events |= ~POLLOUT;
				fds[i].events |= POLLIN;
			}
		}
	}
	delete[] users;
	close(listenfd);
	return 0;
}
同时处理tcp请求和udp请求的回射服务器(epoll)
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <assert.h>
#include <errno.h>
#include <sys/epoll.h>
#include <pthread.h>

#define MAX_EVENT_NUMBER 1024
#define TCP_BUFFER_SIZE 512
#define UDP_BUFFER_SIZE 1024

int setnonblocking(int fd)
{
	int oldopt = fcntl(fd, F_GETFL);
	int newopt = oldopt | O_NONBLOCK;
	fcntl(fd, F_SETFL, newopt);
	return oldopt;
}

void addfd(int epollfd, int fd)
{
	epoll_event event;
	event.data.fd = fd;
	event.events = EPOLLIN | EPOLLET; //ET模式
	epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
	setnonblocking(fd);
}

int main(int argc, char* argv[])
{
	if (argc <= 2)
	{
		printf("usage: %s ip port\n", argv[0]);
		return 1;
	}

	const char* ip = argv[1];
	int port = atoi(argv[2]);

	struct sockaddr_in address;
	bzero(&address, sizeof(address));
	address.sin_family = AF_INET;
	address.sin_port = htons(port);
	inet_pton(AF_INET, ip, &address.sin_addr);

	//TCP
	int listenfd = socket(PF_INET, SOCK_STREAM, 0);
	assert(listenfd >= 0);
	int ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
	assert(ret != -1);
	ret = listen(listenfd, 5);
	assert(ret != -1);

	//UDP
	bzero(&address, sizeof(address));
	address.sin_family = AF_INET;
	address.sin_port = htons(port);
	inet_pton(AF_INET, ip, &address.sin_addr);
	int udpfd = socket(PF_INET, SOCK_DGRAM, 0);
	assert(udpfd >= 0);
	ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
	assert(ret != -1);

	epoll_event events[MAX_EVENT_NUMBER];
	int epollfd = epoll_create(5);
	assert(epollfd != -1);

	addfd(epollfd, listenfd);
	addfd(epollfd, udpfd);

	while (1)
	{
		int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
		if (number < 0)
		{
			printf("epoll failure\n");
			break;
		}

		for (int i = 0; i < number; ++i)
		{
			int sockfd = events[i].data.fd;
			if (sockfd == listenfd)
			{
				//新的TCP连接
				struct sockaddr_in client_address;
				socklen_t client_addrlength = sizeof(client_address);
				int connfd = accept(sockfd, (struct sockaddr*)&client_address, &client_addrlength);
				addfd(epollfd, connfd);
			}
			else if (sockfd == udpfd)
			{
				//udp端口有数据
				char buf[UDP_BUFFER_SIZE];
				memset(buf, '\0', UDP_BUFFER_SIZE);
				struct sockaddr_in client_address;
				socklen_t client_addrlength = sizeof(client_address);
				ret = recvfrom(sockfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr*)&client_address, &client_addrlength);
				if (ret > 0)
				{
					sendto(sockfd, buf, UDP_BUFFER_SIZE - 1, 0, (struct sockaddr*)&client_address, client_addrlength);
				}
			}
			else if (events[i].events & EPOLLIN)
			{
				//tcp连接有数据
				char buf[TCP_BUFFER_SIZE];
				//因为是ET模式  所以需要循环读取数据
				while (1)
				{
					memset(buf, '\0', TCP_BUFFER_SIZE);
					ret = recv(sockfd, buf, TCP_BUFFER_SIZE - 1, 0);
					if (ret < 0)
					{
						if (errno == EAGAIN || errno == EWOULDBLOCK)
						{
							break;
						}
						close(sockfd);
						break;
					}
					else if (ret == 0)
					{
						//对端关闭连接
						close(sockfd);
						break;
					}
					else
					{
						send(sockfd, buf, ret, 0);
					}
				}
			}
			else
			{
				printf("something else happened\n");
			}
		}
	}
	close(listenfd);
	return 0;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值