网络编程中的惊群效应——2

网络编程中的惊群效应——2

备注:此文非原创,仅作为学习笔记,引用的博客链接在最后给出。

1. 操作系统的惊群

  操作系统中的惊群:在多进程/多线程等待同一资源时,也会出现惊群。即当某一资源可用时,多个进程/线程会惊醒,竞争资源。
  网络编程中的惊群种类:

  • accept惊群
  • epoll惊群
  • nginx惊群
  • 线程池惊群

2. 惊群的影响

  • 惊醒所有进程/线程,导致n-1个进程/线程做了无效的调度,上下文切换,cpu瞬时增高
    • 上下文切换:内核进行进程/线程调度切换时,需要CPU保存当前进程的寄存器等相关信心,带来较多的系统开销,而对于多核情况还会有多核的共享数据的处理,这些都是性能开销。
  • 多个进程/线程争抢资源,所以涉及到同步问题,需对资源进行加锁保护,加解锁加大系统CPU开销
  • 在某些情况:惊群次数少/进(线)程负载不高,惊群可以忽略不计

3. 各种惊群的讨论

3.1 accept惊群(新版内核已解决)

  以多进程为例,在主进程创建监听描述符listenfd后,fork()多个子进程,多个进程共享listenfd,accept是在每个子进程中,当一个新连接来的时候,会发生惊群。
  示意图如下:
在这里插入图片描述
在这里插入图片描述  在内核2.6之前,所有进程accept都会惊醒,但只有一个可以accept成功,其他返回EGAIN。
  在内核2.6及之后,解决了惊群,在内核中增加了一个互斥等待变量。一个互斥等待的行为与睡眠基本类似,主要的不同点在于:

  • 当一个等待队列入口有 WQ_FLAG_EXCLUSEVE 标志置位, 它被添加到等待队列的尾部. 没有这个标志的入口项, 相反, 添加到开始.
  • 当 wake_up 被在一个等待队列上调用时, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止。
  • 一段关于accept惊群解决的引用
    • accept惊群的解决
        所有监听同一个socket的进程在内核中中都会被放在这个socket的wait queue中。当一个tcp socket有IO事件变化,都会产生一个wake_up_interruptible()。该系统调用会唤醒wait queue的所有进程。所以修复linux内核的办法是只唤醒一个进程,比如说替换wake函数为wake_one_interruptoble()。
    • 改进版本accept+reuse port
        没有开启reuse选项的socket只有一个wait queue,假设在开启了socket REUSE_PORT选项,内核中为每个进程分配了单独的accept wait queue,每次唤醒wait queue只唤醒有请求的进程。协议栈将socket请求均匀分配给每个accept wait queue。reuse部分解决了惊群问题,但是本身存在一些缺点或bug,比如REUSE实现是根据客户端ip端口实现哈希,对同一个客户请求哈希到同一个服务器进程,但是没有实现一致性哈希。在进程数量扩展新的进程,由于缺少一致性哈希,当listen socket的数目发生变化(比如新服务上线、已存在服务终止)的时候,根据SO_REUSEPORT的路由算法,在客户端和服务端正在进行三次握手的阶段,最终的ACK可能不能正确送达到对应的socket,导致客户端连接发生Connection Reset,所以有些请求会握手失败。

  对于互斥等待的行为,比如如对一个listen后的socket描述符,多线程阻塞accept时,系统内核只会唤醒所有正在等待此时间的队列 的第一个,队列中的其他人则继续等待下一次事件的发生,这样就避免的多个线程同时监听同一个socket描述符时的惊群问题。

3.2 epoll惊群

  liunx 4.5内核在epoll已经新增了EPOLL_EXCLUSIVE选项,在多个进程同时监听同一个socket,只有一个被唤醒。
  epoll的惊群分为两种:

  • 情况1(已解决)在fork之前创建epollfd,所有进程共用一个epoll;
    • step 1. 主进程创建listenfd, 创建epollfd
    • step 2. 主进程fork多个子进程
    • step 3. 每个子进程把listenfd,加到epollfd中
    • step 4. 当一个连接进来时,会触发epoll惊群,多个子进程的epoll同时会触发
    • 分析:这里的epoll惊群跟accept惊群是类似的,共享一个epollfd, 加锁或标记解决。在新版本的epoll中已解决。但在内核2.6及之前是存在的。
  • 情况2(未解决)在fork之后创建epollfd,每个进程独用一个epoll.
    • step 1. 主进程创建listendfd

    • step 2. 主进程创建多个子进程

    • step 3. 每个子进程创建自已的epollfd

    • step 4. 每个子进程把listenfd加入到epollfd中

    • step 5. 当一个连接进来时,会触发epoll惊群,多个子进程epoll同时会触发

    • 分析:因为每个子进程的epoll是不同的epoll, 虽然listenfd是同一个,但新连接过来时, accept会触发惊群,但内核不知道该发给哪个监听进程,因为不是同一个epoll。所以这种惊群内核并没有处理。惊群还是会出现。

  个人理解(不一定对):关于epoll两种情况下的惊群效应,情况1与accept的惊群类似,都是监听同一个epollfd,即红黑树的根节点;而情况2则是监听不同的epollfd根节点,只是在各自的树中都有相同的服务器端节点,所以当来了连接请求时,内核无法进行按照情况1进行处理,因为树根不同,意味着是三棵不同的树,所以惊群效应依然存在。
在这里插入图片描述

实验
  • 情况1,共享epollfd树根
/**
 * epoll惊群测试,共享epollfd树根
 */

#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/wait.h>
#include <unistd.h>

#define IP   "127.0.0.1"
#define PORT  8888
#define PROCESS_NUM 4
#define MAXEVENTS 64

static int create_and_bind() {
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    inet_pton(AF_INET, IP, &serveraddr.sin_addr);
    serveraddr.sin_port = htons(PORT);
    bind(fd, (struct sockaddr *) &serveraddr, sizeof(serveraddr));
    return fd;
}

static int make_socket_non_blocking(int sfd) {
    int flags, s;
    flags = fcntl(sfd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl");
        return -1;
    }
    flags |= O_NONBLOCK;
    s = fcntl(sfd, F_SETFL, flags);
    if (s == -1) {
        perror("fcntl");
        return -1;
    }
    return 0;
}

int worker(int sfd, int efd, struct epoll_event *events, int k) {
    /* The event loop */
    while (1) {
        int n, i;
        n = epoll_wait(efd, events, MAXEVENTS, -1);
        sleep(1);	/* 确保能够看到惊群现象 */
        printf("worker %d return from epoll_wait!\n", k);
        for (int i = 0; i < n; i++) {
            /* 文件描述符发生错误,被挂断,不可读 */
            if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))) {
                fprintf(stderr, "epoll error\n");
                close(events[i].data.fd);
                continue;
            }
                /* 到来一个连接请求 */
            else if (sfd == events[i].data.fd) {
                struct sockaddr in_addr;
                socklen_t in_len;

                char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
                in_len = sizeof(in_addr);
                int infd = accept(sfd, &in_addr, &in_len);
                if (infd == -1) {
                    printf("woker %d accept failed!\n", k);
                    break;
                }
                printf("woker %d accept successed!\n", k);
                /* 将connfd设置为非阻塞并加入到epoll的监听树上 */
                close(infd);
            }
        }
    }
    return 0;
}

int main() {
    int sfd, s;
    int efd;
    struct epoll_event event;
    struct epoll_event *events;
    sfd = create_and_bind();
    if (sfd == -1) {
        abort();
    }
    s = make_socket_non_blocking(sfd);
    if (s == -1) {
        abort();
    }
    s = listen(sfd, SOMAXCONN);
    if (s == -1) {
        perror("listen");
        abort();
    }

    efd = epoll_create(MAXEVENTS);
    if (efd == -1) {
        perror("epoll_create");
        abort();
    }

    event.data.fd = sfd;

    int op = 1;                                         /* 此处自己设置测试的环境 */
    if(op == 1){
        event.events = EPOLLIN;                         /* LT模式,读事件 */
    }
    else if(op == 2){
        event.events = EPOLLIN|EPOLLET;                 /* ET模式,读事件 */
    }
    else if(op == 3){
        event.events = EPOLLIN|EPOLLEXCLUSIVE;          /* LT模式,EPOLLEXCLUSIVE选项,读事件 */
    }
    else if(op == 4){
        event.events = EPOLLIN|EPOLLET|EPOLLEXCLUSIVE;  /* ET模式,EPOLLEXCLUSIVE选项,读事件 */
    }

    s = epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event);
    if (s == -1) {
        perror("epoll_ctl");
        abort();
    }

    events = calloc(MAXEVENTS, sizeof(event));
    for (int i = 0; i < PROCESS_NUM; i++) {
        printf("Create worker %d\n", i + 1);
        int pid = fork();
        if (pid == 0) { /* 子进程 */
            printf("I am %dth sub process,pid = %d!", i, pid);
            worker(sfd, efd, events, i);    /* 新进程开始epoll监听 */
        }
    }

    int status;
    wait(&status);
    free(events);
    close(sfd);
    return EXIT_SUCCESS;
}
  • 情况2,独享epollfd树根
/**
* epoll惊群测试,独享epollfd树根
*/

#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/wait.h>
#include <unistd.h>


#define IP   "127.0.0.1"
#define PORT  8888
#define PROCESS_NUM 4
#define MAXEVENTS 64

static int create_and_bind() {
   int fd = socket(PF_INET, SOCK_STREAM, 0);
   struct sockaddr_in serveraddr;
   serveraddr.sin_family = AF_INET;
   inet_pton(AF_INET, IP, &serveraddr.sin_addr);
   serveraddr.sin_port = htons(PORT);
   bind(fd, (struct sockaddr *) &serveraddr, sizeof(serveraddr));
   return fd;
}

static int make_socket_non_blocking(int sfd) {
   int flags, s;
   flags = fcntl(sfd, F_GETFL, 0);
   if (flags == -1) {
       perror("fcntl");
       return -1;
   }
   flags |= O_NONBLOCK;
   s = fcntl(sfd, F_SETFL, flags);
   if (s == -1) {
       perror("fcntl");
       return -1;
   }
   return 0;
}

int worker(int sfd, int k) {
   int efd = epoll_create(MAXEVENTS);
   if (efd == -1) {
       perror("epoll_create");
       abort();
   }
   struct epoll_event event;
   struct epoll_event *events;
   event.data.fd = sfd;

   int op = 1;                                         /* 此处自己设置测试的环境 */
   if(op == 1){
       event.events = EPOLLIN;                         /* LT模式,读事件 */
   }
   else if(op == 2){
       event.events = EPOLLIN|EPOLLET;                 /* ET模式,读事件 */
   }
   else if(op == 3){
       event.events = EPOLLIN|EPOLLEXCLUSIVE;          /* LT模式,EPOLLEXCLUSIVE选项,读事件 */
   }
   else if(op == 4){
       event.events = EPOLLIN|EPOLLET|EPOLLEXCLUSIVE;  /* ET模式,EPOLLEXCLUSIVE选项,读事件 */
   }

   int s = epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event);
   if (s == -1) {
       perror("epoll_ctl");
       abort();
   }

   events = calloc(MAXEVENTS, sizeof(event));
   /* The event loop */
   while (1) {
       int n, i;
       n = epoll_wait(efd, events, MAXEVENTS, -1);
       sleep(1);	/* 确保能够看到惊群现象 */
       printf("worker %d return from epoll_wait!\n", k);
       for (int i = 0; i < n; i++) {
           /* 文件描述符发生错误,被挂断,不可读 */
           if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))) {
               fprintf(stderr, "epoll error\n");
               close(events[i].data.fd);
               continue;
           }
               /* 到来一个连接请求 */
           else if (sfd == events[i].data.fd) {
               struct sockaddr in_addr;
               socklen_t in_len;

               char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
               in_len = sizeof(in_addr);
               int infd = accept(sfd, &in_addr, &in_len);
               if (infd == -1) {
                   printf("woker %d accept failed!\n", k);
                   break;
               }
               printf("woker %d accept successed!\n", k);
               /* 将connfd设置为非阻塞并加入到epoll的监听树上 */
               close(infd);
           }
       }
   }
   free(events);
   return 0;
}

int main() {
   int sfd, s;

   sfd = create_and_bind();
   if (sfd == -1) {
       abort();
   }
   s = make_socket_non_blocking(sfd);
   if (s == -1) {
       abort();
   }
   s = listen(sfd, SOMAXCONN);
   if (s == -1) {
       perror("listen");
       abort();
   }


   for (int i = 0; i < PROCESS_NUM; i++) {
       printf("Create worker %d\n", i + 1);
       int pid = fork();
       if (pid == 0) { /* 子进程 */
           printf("I am %dth sub process,pid = %d!", i, pid);
           worker(sfd, i);    /* 新进程开始epoll监听 */
       }
   }

   int status;
   wait(&status);
   close(sfd);
   return EXIT_SUCCESS;
}

  • 运行方式如下:
    编译运行
    在这里插入图片描述
    连接测试
    在这里插入图片描述
    出现惊群的效果:
    在这里插入图片描述
    没有出现惊群的运行效果:
    在这里插入图片描述
实验结果
  • 不添加 EPOLLEXCLUSIVE
项目LT模式ET模式
共享epoll_fd存在不存在
独享epoll_fd存在存在
  • 添加EPOLLEXCLUSIVE
项目LT模式ET模式
共享epoll_fd存在不存在
独享epoll_fd不存在不存在

3.3 nginx惊群

谈一下nginx的惊群,网上大多是从源码角度分析的,都已经写的很清楚详尽了。
贴几个网上分析较好的源码:

  • accept_mutex打开设置
if (ccf->master && ccf->worker_processes > 1 && ecf->accept_mutex) {  
    ngx_use_accept_mutex = 1;  
    ngx_accept_mutex_held = 0;  
    ngx_accept_mutex_delay = ecf->accept_mutex_delay;  
} else {  
    ngx_use_accept_mutex = 0;  
}  
  • ngx_process_events_and_timers
    每个工作中的定时回调函数
void  
ngx_process_events_and_timers(ngx_cycle_t *cycle)  
{  
。。。 。。。  
    // ngx_use_accept_mutex表示是否需要通过对accept加锁来解决惊群问题。
    // 当nginx worker进程数>1时且配置文件中打开accept_mutex时,这个标志置为1  
    if (ngx_use_accept_mutex) {  
    	// ngx_accept_disabled表示此时满负荷,没必要再处理新连接了,
    	// 我们在nginx.conf曾经配置了每一个nginx worker进程能够处理的最大连接数,
    	// 当达到最大数的7/8时,ngx_accept_disabled为正,
    	// 说明本nginx worker进程非常繁忙,将不再去处理新连接,这也是个简单的负载均衡  
        if (ngx_accept_disabled > 0) {  
            ngx_accept_disabled--;  
        } else {  
            // 获得accept锁,多个worker仅有一个可以得到这把锁。
            // 获得锁不是阻塞过程,都是立刻返回,获取成功的话ngx_accept_mutex_held被置为1。
            // 拿到锁,意味着监听句柄被放到本进程的epoll中了,
            // 如果没有拿到锁,则监听句柄会被从epoll中取出。  
            if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {  
                return;  
            }  
            // 拿到锁的话,置flag为NGX_POST_EVENTS,这意味着ngx_process_events函数中,
            // 任何事件都将延后处理,会把accept事件都放到ngx_posted_accept_events链表中,
            // epollin|epollout事件都放到ngx_posted_events链表中  
            if (ngx_accept_mutex_held) {  
                flags |= NGX_POST_EVENTS;  
            } else {  
                // 拿不到锁,也就不会处理监听的句柄,这个timer实际是传给epoll_wait的超时时间,
                // 修改为最大ngx_accept_mutex_delay意味着epoll_wait更短的超时返回,
                // 以免新连接长时间没有得到处理  
                if (timer == NGX_TIMER_INFINITE  
                    || timer > ngx_accept_mutex_delay)  
                {  
                    timer = ngx_accept_mutex_delay;  
                }  
            }  
        }  
    }  
。。。 。。。  
    // linux下,调用ngx_epoll_process_events函数开始处理  
    (void) ngx_process_events(cycle, timer, flags);  
。。。 。。。  
    // 如果ngx_posted_accept_events链表有数据,就开始accept建立新连接  
    if (ngx_posted_accept_events) {  
        ngx_event_process_posted(cycle, &ngx_posted_accept_events);  
    }  
    // 释放锁后再处理下面的EPOLLIN EPOLLOUT请求  
    if (ngx_accept_mutex_held) {  
        ngx_shmtx_unlock(&ngx_accept_mutex);  
    }  
    if (delta) {  
        ngx_event_expire_timers();  
    }  
    ngx_log_debug1(NGX_LOG_DEBUG_EVENT, cycle->log, 0,  
                   "posted events %p", ngx_posted_events);  
    // 然后再处理正常的数据读写请求。因为这些请求耗时久,
    // 所以在ngx_process_events里NGX_POST_EVENTS标志将事件都放入ngx_posted_events链表中,
    // 延迟到锁释放了再处理。  
    if (ngx_posted_events) {  
        if (ngx_threaded) {  
            ngx_wakeup_worker_thread(cycle);  
        } else {  
            ngx_event_process_posted(cycle, &ngx_posted_events);  
        }  
    }  
}  
  • ngx_trylock_accept_mutex函数
ngx_int_t  
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)  
{  
	//ngx_shmtx_trylock是非阻塞取锁的,返回1表示成功,0表示没取到锁  
    if (ngx_shmtx_trylock(&ngx_accept_mutex)) {  
	//ngx_enable_accept_events会把监听的句柄都塞入到本worker进程的epoll中  
        if (ngx_enable_accept_events(cycle) == NGX_ERROR) {  
            ngx_shmtx_unlock(&ngx_accept_mutex);  
            return NGX_ERROR;  
        }  
	//ngx_accept_mutex_held置为1,表示拿到锁了,返回  
        ngx_accept_events = 0;  
        ngx_accept_mutex_held = 1;  
        return NGX_OK;  
    }  
	//处理没有拿到锁的逻辑,ngx_disable_accept_events会把监听句柄从epoll中取出  
    if (ngx_accept_mutex_held) {  
        if (ngx_disable_accept_events(cycle) == NGX_ERROR) {  
            return NGX_ERROR;  
        }  
        ngx_accept_mutex_held = 0;  
    }  
    return NGX_OK;  
}                           
  • ngx_epoll_process_events函数
static ngx_int_t  
ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags)  
{  
。。。 。。。  
    events = epoll_wait(ep, event_list, (int) nevents, timer);  
。。。 。。。  
    ngx_mutex_lock(ngx_posted_events_mutex);  
    for (i = 0; i < events; i++) {  
        c = event_list[i].data.ptr;  
。。。 。。。  
        rev = c->read;  
        if ((revents & EPOLLIN) && rev->active) {  
。。。 。。。  
	//有NGX_POST_EVENTS标志的话,就把accept事件放到ngx_posted_accept_events队列中,
	//把正常的事件放到ngx_posted_events队列中延迟处理  
            if (flags & NGX_POST_EVENTS) {  
                queue = (ngx_event_t **) (rev->accept ?  
                               &ngx_posted_accept_events : &ngx_posted_events);  
                ngx_locked_post_event(rev, queue);  
            } else {  
                rev->handler(rev);  
            }  
        }  
        wev = c->write;  
        if ((revents & EPOLLOUT) && wev->active) {  
。。。 。。。  
	//同理,有NGX_POST_EVENTS标志的话,写事件延迟处理,放到ngx_posted_events队列中  
            if (flags & NGX_POST_EVENTS) {  
                ngx_locked_post_event(wev, &ngx_posted_events);  
            } else {  
                wev->handler(wev);  
            }  
        }  
    }  
    ngx_mutex_unlock(ngx_posted_events_mutex);  
    return NGX_OK;  
}  

这个我根据自己理解画的一个流程图,如有错误,欢迎指正
在这里插入图片描述
下图来自博客:https://blog.csdn.net/second60/article/details/81252106
在这里插入图片描述
其他博主的总结:
  就是同一时刻只允许一个nginx worker在自己的epoll中处理监听句柄。它的负载均衡也很简单,当达到最大connection的7/8时,本worker不会去试图拿accept锁,也不会去处理新连接,这样其他nginx worker进程就更有机会去处理监听句柄,建立新连接了。而且,由于timeout的设定,使得没有拿到锁的worker进程,去拿锁的频繁更高。
两个细节问题:
  第一,如果在处理新建连接事件的过程中,在监听套接字接口上又来了新的请求会怎么样?这没有关系,当前进程只处理已缓存的事件,新的请求将被阻塞在监听套接字接口上,并且监听套接字接口是以水平方式加入到事件监控机制里的,所以等到下一轮被哪个进程争取到锁并加在事件监控机制里时才会触发而被抓取出来。
  第二,在进程处理事件时,只是把锁释放了,而没有将监听套接字接口从事件监控机制里删除,所以当处理缓存事件过程中,互斥锁可能会被另外一个进程争抢到并把所有监听套接字接口加入到它的事件监控机制里。因此严格来说,在同一时刻,监听套接字可能被多个进程拥有,但是,在同一时刻,监听套接口只可能被一个进程监控,因此在进程处理完缓存事件之后去争抢锁,发现锁被其他进程占用而争抢失败,会把所有监听套接口从自身的事件监控机制删除,然后才进行事件监控。在同一时刻,监听套接口只可能被一个进程监控,这也就意味着Nginx根本不会受到惊群的影响。

3.4 线程池惊群

  线程池惊群是很好避免的,毕竟线程库本来就设计好了对应的函数,自己在多线程编程时注意即可。
  线程池中的”惊群”:一个基本的线程池框架是基于生产者和消费者模型的。生产者往队列里面添加任务,而消费者从队列中取任务并进行执行。一般来说,消费时间比较长,一般有许多个消费者。当许多个消费者同时在等待任务队列的时候,也就发生了“惊群效应”。
  pthread_cond_broadcast,这个是广播给所有等待任务的消费者,会产生惊群效应。
  pthread_cond_signal,不会有“惊群现象”产生,它最多只给一个线程发信号。
  假如有多个线程正在阻塞等待着这个条件变量的话,那么是根据各等待线程优先级的高低确定哪个线程接收到信号开始继续执行。如果各线程优先级相同,则根据等待时间的长短来确定哪个线程获得信号。但无论如何一个pthread_cond_signal调用最多发信一次。
  正常的用法:
    (1) 所有线程共用一个锁,共用一个条件变量
    (2) 当pthread_cond_signal通知时,就可能会出现惊群
  解决惊群的方法:
    (1) 所有线程共用一个锁,每个线程有自已的条件变量
    (2) pthread_cond_signal通知时,定向通知某个线程的条件变量,不会出现惊群

4. 参考博客

https://blog.csdn.net/second60/article/details/81252106
https://blog.csdn.net/lyztyycode/article/details/78648798
https://cloud.tencent.com/developer/article/1340628
https://www.iteye.com/blog/russelltao-1405352
https://blog.csdn.net/wan_hust/article/details/38958545

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值