SOCKET编程(5):IO复用

IO复用

多进程/线程并发模型,为每个sockets分配一个进程/线程

I/O(多路)复用,采用单个进/线程就可以管理多个socket

I/O复用有3种方案:

  • select
  • poll
  • epoll

select

I/O多路复用详解

27、fd_set与FD_SETSIZE详解

详解fd_set结构体

fd_set结构体

#include <sys/select.h>

#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)

typedef struct {
        unsigned long fds_bits[__FDSET_LONGS];
} fd_set;

或者

typedef struct{
    long int fds_bits[32];
}fd_set;

fd_set 是文件描述符 fd 的集合,由于每个进程可打开的文件描述符默认值为1024,fd_set可记录的 fd 个数上限也是1024个

fd_set 采用位图 bitmap 结构,是一个大小为32的 long 型数组,每一个 bit 代表一个描述符是否被监视(类似于一个32x32的矩阵)

操作函数

#include <sys/select.h>
#include <sys/time.h>
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
void FD_ISSET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

FD_ZERO(&fdset); /将set清零使集合中不含任何fd,清空fdset与所有文件句柄的联系/
FD_SET(fd, &fdset); /将fd加入set集合,建立文件句柄fd与fdset的联系/
FD_CLR(fd, &fdset); /将fd从set集合中清除,清除文件句柄fd与fdset的联系/
FD_ISSET(fd, &fdset); /在调用select()函数后,用FD_ISSET来检测fd是否在set集合中,当检测到fd在set中则返回真,否则,返回假(0)/

select()函数

// nfds:fds中最大fd的值加1
// readfds: 读数据文件描述符集合
// writefds: 写数据文件描述符集合
// exceptfds: 异常情况的文件描述符集合
// timeout: 该方法阻塞的超时时间
int select (int nfds, fd_set *readfds, 
            fd_set *writefds, 
            fd_set *exceptfds, 
            struct timeval *timeout);
                                     
struct timeval {
    long tv_sec;  //秒
    long tv_usec; //毫秒
}
  • 用户进程通过 select 系统调用把 fd_set 结构的数据拷贝到内核,由内核来监视并判断哪些连接有数据到来,如果有连接准备好数据,select 系统调用就返回
  • select 返回后,用户进程只知道某个或某几个连接有数据,但并不知道是哪个连接。所以需要遍历 fds 中的每个 fd, 当该 fd 被置位时,代表该 fd 表示的连接有数据需要被读取。然后我们读取该 fd 的数据并进行业务操作
  • select 第一个参数需要传入最大fd值加1的数值,目的是为了用户能自定义监视的 fd 范围,防止不必要资源消耗
  • 操作系统会复用用户进程传入的 fd_set 变量,来作为出参,所以我们传入的 fd_set 返回时已经被内核修改过了
  • select 的方式选择让内核来帮我们监视这些 fd,当有数据可读时就通知我们,避免listenfd在accept()时阻塞,提升了效率

返回值:

  • **>0:**有事件发生
  • **=0:**timeout,超时
  • **<0:**出错

示例程序

  • tcpseletc.cpp

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/socket.h>
    #include <arpa/inet.h>
    #include <sys/fcntl.h>
    
    // 初始化服务端的监听端口。
    int initserver(int port);
    
    int main(int argc,char *argv[])
    {
      if (argc != 2)
      {
        printf("usage: ./tcpselect port\n"); return -1;
      }
    
      // 初始化服务端用于监听的socket。
      int listensock = initserver(atoi(argv[1]));
      printf("listensock=%d\n",listensock);
    
      if (listensock < 0)
      {
        printf("initserver() failed.\n"); return -1;
      }
    
      fd_set readfdset;  // 读事件的集合,包括监听socket和客户端连接上来的socket。
      int maxfd;  // readfdset中socket的最大值。
    
      // 初始化结构体,把listensock添加到集合中。
      FD_ZERO(&readfdset);
    
      FD_SET(listensock,&readfdset);
      maxfd = listensock;
    
      while (1)
      {
        // 调用select函数时,会改变socket集合的内容,所以要把socket集合保存下来,传一个临时的给select。
        fd_set tmpfdset = readfdset;
    
        int infds = select(maxfd+1,&tmpfdset,NULL,NULL,NULL);
        // printf("select infds=%d\n",infds);
    
        // 返回失败。
        if (infds < 0)
        {
          printf("select() failed.\n"); perror("select()"); break;
        }
    
        // 超时,在本程序中,select函数最后一个参数为空,不存在超时的情况,但以下代码还是留着。
        if (infds == 0)
        {
          printf("select() timeout.\n"); continue;
        }
    
        // 检查有事情发生的socket,包括监听和客户端连接的socket。
        // 这里是客户端的socket事件,每次都要遍历整个集合,因为可能有多个socket有事件。
        for (int eventfd=0; eventfd <= maxfd; eventfd++)
        {
          if (FD_ISSET(eventfd,&tmpfdset)<=0) continue;     //判断时用tmpfdset集合
    
          if (eventfd==listensock)
          { 
            // 如果发生事件的是listensock,表示有新的客户端连上来。
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int clientsock = accept(listensock,(struct sockaddr*)&client,&len);
            if (clientsock < 0)
            {
              printf("accept() failed.\n"); continue;
            }
    
            printf ("client(socket=%d) connected ok.\n",clientsock);
    
            // 把新的客户端socket加入集合,readfdset集合,注意区别何时用tmpfdset何时用readfdset
            FD_SET(clientsock,&readfdset);
    
            if (maxfd < clientsock) maxfd = clientsock;
    
            continue;
          }
          else
          {
            // 客户端有数据过来或客户端的socket连接被断开。
            char buffer[1024];
            memset(buffer,0,sizeof(buffer));
    
            // 读取客户端的数据。
            ssize_t isize=read(eventfd,buffer,sizeof(buffer));
    
            // 发生了错误或socket被对方关闭。
            if (isize <=0)
            {
              printf("client(eventfd=%d) disconnected.\n",eventfd);
    
              close(eventfd);  // 关闭客户端的socket。
    
              FD_CLR(eventfd,&readfdset);  // 从readfdset集合中移去客户端的socket。
    
              // 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。
              if (eventfd == maxfd)
              {
                for (int ii=maxfd;ii>0;ii--)
                {
                  if (FD_ISSET(ii,&readfdset))
                  {
                    maxfd = ii; break;
                  }
                }
    
                printf("maxfd=%d\n",maxfd);
              }
    
              continue;
            }
    
            printf("recv(eventfd=%d,size=%d):%s\n",eventfd,isize,buffer);
    
            // 把收到的报文发回给客户端。
            write(eventfd,buffer,strlen(buffer));
          }
        }
      }
    
      return 0;
    }
    
    // 初始化服务端的监听端口。
    int initserver(int port)
    {
      int sock = socket(AF_INET,SOCK_STREAM,0);
      if (sock < 0)
      {
        printf("socket() failed.\n"); return -1;
      }
    
      // Linux如下
      int opt = 1; unsigned int len = sizeof(opt);
      setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,len);
      setsockopt(sock,SOL_SOCKET,SO_KEEPALIVE,&opt,len);
    
      struct sockaddr_in servaddr;
      servaddr.sin_family = AF_INET;
      servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
      servaddr.sin_port = htons(port);
    
      if (bind(sock,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0 )
      {
        printf("bind() failed.\n"); close(sock); return -1;
      }
    
      if (listen(sock,5) != 0 )
      {
        printf("listen() failed.\n"); close(sock); return -1;
      }
    
      return sock;
    }
    

select缺陷与不足

  1. 可监控的文件描述符数量最大为 1024 个,就代表最大能支持的并发为1024,这个是操作系统内核决定的
  2. 用户进程的文件描述符集合 fd_set 每次都需要从用户进程拷贝到内核,有一定的性能开销
  3. select 函数返回,我们只知道有文件描述符满足要求,但不知道是哪个,所以需要遍历所有文件描述符,复杂度为O(n)
  4. select 机制的这些特性在高并发网络服务器动辄几万几十万并发连接的场景下是低效的

poll

poll 是另一种I/O多路复用的实现方式,它解决了 select 1024个文件描述符的限制问题

poll 是使用 pollfd 结构来替代了 selectfd_set 位图,以解决 1024 的文件描述符个数限制

struct pollfd 
{
	int   fd;         /* file descriptor */
	short events;     /* requested events */
	short revents;    /* returned events */
};
  • fd 表示要监视的文件描述符
  • events 表示要监视的事件,比如输入、输出或异常
  • revents 表示返回的标志位,标识哪个事件有信息到来,处理完成后记得重置标志位

poll 函数的定义

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • poll 函数的第一个参数传入了一个自定义的 pollfd 的数组,原则上已经没有了个数的限制
  • poll 除了解决了 select 存在的文件描述符个数的限制,并没有解决 select 存在的其他问题(拷贝轮询)
  • selectpoll 都会随着监控的文件描述符数量增加而性能下降,因此也不太适合高并发场景

epoll

epoll 使用一个文件描述符管理多个描述符,省去了大量文件描述符频繁在用户态和内核态之间拷贝的资源消耗

epoll 操作过程有三个非常重要的接口

epoll_create()函数

/* Creates an epoll instance.  Returns an fd for the new instance.
   The "size" parameter is a hint specifying the number of file
   descriptors to be associated with the new instance.  The fd
   returned by epoll_create() should be closed with close().  */
extern int epoll_create (int __size) __THROW;

/* Same as epoll_create but with an FLAGS parameter.  The unused SIZE
   parameter has been dropped.  */
extern int epoll_create1 (int __flags) __THROW;

epoll_create() 方法生成一个 epoll 专用的文件描述符(创建一个 epoll 的句柄)

参数 size 在新版本中没有具体意义,填一个大于0的任意值即可

epoll_ctl()函数

/* Manipulate an epoll instance "epfd". Returns 0 in case of success,
   -1 in case of error ( the "errno" variable will contain the
   specific error code ) The "op" parameter is one of the EPOLL_CTL_*
   constants defined above. The "fd" parameter is the target of the
   operation. The "event" parameter describes which events the caller
   is interested in and any associated user data.  */
extern int epoll_ctl (int __epfd, int __op, int __fd,
		      struct epoll_event *__event) __THROW;
  • epfdepoll 专用的文件描述符,epoll_create() 的返回值
  • op:表示添加、修改、删除的动作,用三个宏来表示:
/* Valid opcodes ( "op" parameter ) to issue to epoll_ctl().  */
#define EPOLL_CTL_ADD 1	/* Add a file descriptor to the interface.  */
#define EPOLL_CTL_DEL 2	/* Remove a file descriptor from the interface.  */
#define EPOLL_CTL_MOD 3	/* Change file descriptor epoll_event structure.  */
  • fd:需要监听的文件描述符
  • event:告诉内核要监听的事件

epoll_event结构体

前端 详解epoll_events结构体

typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event
{
  uint32_t events;	/* Epoll events */
  epoll_data_t data;	/* User data variable */
} __EPOLL_PACKED;

定义了枚举类型的events

enum EPOLL_EVENTS
  {
    EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN
    EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI
    EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT
    EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM
    EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND
    EPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORM
    EPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBAND
    EPOLLMSG = 0x400,
#define EPOLLMSG EPOLLMSG
    EPOLLERR = 0x008,
#define EPOLLERR EPOLLERR
    EPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUP
    EPOLLRDHUP = 0x2000,
#define EPOLLRDHUP EPOLLRDHUP
    EPOLLEXCLUSIVE = 1u << 28,
#define EPOLLEXCLUSIVE EPOLLEXCLUSIVE
    EPOLLWAKEUP = 1u << 29,
#define EPOLLWAKEUP EPOLLWAKEUP
    EPOLLONESHOT = 1u << 30,
#define EPOLLONESHOT EPOLLONESHOT
    EPOLLET = 1u << 31
#define EPOLLET EPOLLET
  };

epoll_wait()函数

/* Wait for events on an epoll instance "epfd". Returns the number of
   triggered events returned in "events" buffer. Or -1 in case of
   error with the "errno" variable set to the specific error code. The
   "events" parameter is a buffer that will contain triggered
   events. The "maxevents" is the maximum number of events to be
   returned ( usually size of "events" ). The "timeout" parameter
   specifies the maximum wait time in milliseconds (-1 == infinite).

   This function is a cancellation point and therefore not marked with
   __THROW.  */
extern int epoll_wait (int __epfd, struct epoll_event *__events,
		       int __maxevents, int __timeout);

epoll_wait() 方法等待事件的产生,类似 select 调用

  • epfdepoll 专用的文件描述符,epoll_create() 的返回值
  • events:分配好的 epoll_event 结构体数组,epoll 将会把发生的事件赋值到 events 数组中
  • maxevents:告诉内核 events 数组的大小
  • timeout:超时时间,单位毫秒,为 -1 时,方法为阻塞

值得注意的是epoll_wait()函数只能获取是否有注册事件发生,至于这个事件到底是什么、从哪个 socket 来、发送的时间、包的大小等等信息,统统不知道。这就好比一个人在黑黢黢的山洞里,只能听到声响,至于这个声音是谁发出的根本不知道。因此我们就需要struct epoll_event来帮助我们读取信息

实例

  • tcpepoll.cpp

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <errno.h>
    #include <unistd.h>
    #include <fcntl.h>
    #include <arpa/inet.h>
    #include <netinet/in.h>
    #include <sys/epoll.h>
    #include <sys/socket.h>
    #include <sys/types.h>
    
    #define MAXEVENTS 100
    
    // 把socket设置为非阻塞的方式。
    int setnonblocking(int sockfd);
    
    // 初始化服务端的监听端口。
    int initserver(int port);
    
    int main(int argc, char *argv[])
    {
      if (argc != 2)
      {
        printf("usage:./tcpepoll port\n");
        return -1;
      }
    
      // 初始化服务端用于监听的socket。
      int listensock = initserver(atoi(argv[1]));
      printf("listensock=%d\n", listensock);
    
      if (listensock < 0)
      {
        printf("initserver() failed.\n");
        return -1;
      }
    
      int epollfd;
    
      char buffer[1024];
      memset(buffer, 0, sizeof(buffer));
    
      // 创建一个描述符
      epollfd = epoll_create(1);
    
      // 添加监听描述符事件
      struct epoll_event ev;
      ev.data.fd = listensock;
      ev.events = EPOLLIN;
      epoll_ctl(epollfd, EPOLL_CTL_ADD, listensock, &ev);
    
      while (1)
      {
        struct epoll_event events[MAXEVENTS]; // 存放有事件发生的结构数组。
    
        // 等待监视的socket有事件发生。
        int infds = epoll_wait(epollfd, events, MAXEVENTS, -1);
        // printf("epoll_wait infds=%d\n",infds);
    
        // 返回失败。
        if (infds < 0)
        {
          printf("epoll_wait() failed.\n");
          perror("epoll_wait()");
          break;
        }
    
        // 超时。
        if (infds == 0)
        {
          printf("epoll_wait() timeout.\n");
          continue;
        }
    
        // 遍历有事件发生的结构数组。
        for (int ii = 0; ii < infds; ii++)
        {
          if ((events[ii].data.fd == listensock) && (events[ii].events & EPOLLIN))
          {
            // 如果发生事件的是listensock,表示有新的客户端连上来。
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int clientsock = accept(listensock, (struct sockaddr *)&client, &len);
            if (clientsock < 0)
            {
              printf("accept() failed.\n");
              continue;
            }
    
            // 把新的客户端添加到epoll中。
            memset(&ev, 0, sizeof(struct epoll_event));
            ev.data.fd = clientsock;
            ev.events = EPOLLIN;
            epoll_ctl(epollfd, EPOLL_CTL_ADD, clientsock, &ev);
    
            printf("client(socket=%d) connected ok.\n", clientsock);
    
            continue;
          }
          else if (events[ii].events & EPOLLIN)
          {
            // 客户端有数据过来或客户端的socket连接被断开。
            char buffer[1024];
            memset(buffer, 0, sizeof(buffer));
    
            // 读取客户端的数据。
            ssize_t isize = read(events[ii].data.fd, buffer, sizeof(buffer));
    
            // 发生了错误或socket被对方关闭。
            if (isize <= 0)
            {
              printf("client(eventfd=%d) disconnected.\n", events[ii].data.fd);
    
              // 把已断开的客户端从epoll中删除。
              memset(&ev, 0, sizeof(struct epoll_event));
              ev.events = EPOLLIN;
              ev.data.fd = events[ii].data.fd;
              epoll_ctl(epollfd, EPOLL_CTL_DEL, events[ii].data.fd, &ev);
              close(events[ii].data.fd); //或者一行关闭命令即可
              continue;
            }
    
            printf("recv(eventfd=%d,size=%d):%s\n", events[ii].data.fd, isize, buffer);
    
            // 把收到的报文发回给客户端。
            write(events[ii].data.fd, buffer, strlen(buffer));
          }
        }
      }
    
      close(epollfd);
    
      return 0;
    }
    
    // 初始化服务端的监听端口。
    int initserver(int port)
    {
      int sock = socket(AF_INET, SOCK_STREAM, 0);
      if (sock < 0)
      {
        printf("socket() failed.\n");
        return -1;
      }
    
      // Linux如下
      int opt = 1;
      unsigned int len = sizeof(opt);
      setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);
      setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &opt, len);
    
      struct sockaddr_in servaddr;
      servaddr.sin_family = AF_INET;
      servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
      servaddr.sin_port = htons(port);
    
      if (bind(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0)
      {
        printf("bind() failed.\n");
        close(sock);
        return -1;
      }
    
      if (listen(sock, 5) != 0)
      {
        printf("listen() failed.\n");
        close(sock);
        return -1;
      }
    
      return sock;
    }
    
    // 把socket设置为非阻塞的方式。
    int setnonblocking(int sockfd)
    {
      if (fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK) == -1)
        return -1;
    
      return 0;
    }
    

小结

epoll 底层使用了 RB-Tree 红黑树和 list 链表实现。内核创建了红黑树用于存储 epoll_ctl 传来的 socket,另外创建了一个 list 链表,用于存储准备就绪的事件

epoll_wait 调用时,仅仅观察这个 list 链表里有没有数据即可。有数据就返回,没有数据就阻塞。所以,epoll_wait 非常高效,通常情况下即使我们要监控百万计的连接,大多一次也只返回很少量准备就绪的文件描述符而已,所以,epoll_wait 仅需要从内核态拷贝很少的文件描述符到用户态

epoll 相比于 selectpoll,它更高效的本质在于:

  1. 减少了用户态和内核态文件描述符状态的拷贝,epoll 只需要一个专用的文件句柄即可
  2. 减少了文件描述符的遍历,selectpoll 每次都要遍历所有的文件描述符,用来判断哪个连接准备就绪;epoll 返回的是准备就绪的文件描述符,效率大大提高
  3. 没有并发数量的限制,性能不会随文件描述符数量的增加而下降

IO复用总结

select 是较早实现的一种I/O多路复用技术,但它最明显的缺点就是有 1024 个文件描述符数量的限制,也就导致它无法满足高并发的需求

poll 一定程度上解决了 select 文件描述符数量的限制,但和 select 一样,仍然存在文件描述符状态在用户态和内核态的频繁拷贝,和遍历所有文件描述符的问题,这导致了在面对高并发的实现需求时,它的性能不会很高

epoll 高效地解决了以上问题,首先使用一个特殊的文件描述符,解决了用户态和内核态频繁拷贝的问题;其次 epoll_wait 返回的是准备就绪的文件描述符,省去了无效的遍历;再次,底层使用红黑树和链表的数据结构,更加高效地实现连接的监视

工作中常用的 redis、nginx 都是使用了 epoll 这种I/O复用模型,通过单线程就实现了10万以上的并发访问

epoll 不一定任何情况下都比 select 高效,需要根据具体场景。比如并发不是很高,且大部分都是活跃的 socket,那么也许 select 会比 epoll 更加高效,因为 epoll 会有更多次的系统调用,用户态和内核态会有更加频繁的切换

  • 36
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
以下是一个简单的 Java 代码示例,用于实现 socket 编程中的信道复用技术: ``` import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; public class SocketMultiplexer { private Selector selector; private ServerSocketChannel serverSocketChannel; public void init(int port) throws IOException { selector = Selector.open(); serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(port)); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); } public void run() throws IOException { while (true) { selector.select(); Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); if (key.isAcceptable()) { accept(key); } else if (key.isReadable()) { read(key); } } } } private void accept(SelectionKey key) throws IOException { ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel(); SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } private void read(SelectionKey key) throws IOException { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int numRead = socketChannel.read(buffer); if (numRead == -1) { socketChannel.close(); key.cancel(); return; } byte[] data = new byte[numRead]; System.arraycopy(buffer.array(), 0, data, 0, numRead); System.out.println("Received: " + new String(data)); } public static void main(String[] args) throws IOException { SocketMultiplexer multiplexer = new SocketMultiplexer(); multiplexer.init(8080); multiplexer.run(); } } ``` 这个示例代码实现了一个简单的服务器,监听本地的 8080 端口,并使用 selector 实现了信道复用。当有新的连接请求时,会调用 accept() 方法接受连接,并将新的 SocketChannel 注册到 selector 中,以便后续处理读写事件。当有数据可读时,会调用 read() 方法读取数据,并输出到控制台。 希望这个示例能够帮助你理解 socket 编程中的信道复用技术。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Prejudices

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值