java 协程 quasar 从原理到代码应用

简介

java开发对线程都不陌生,一个进程可以产生许多线程,每个线程有自己的上下文,但是每个线程也都有自己的消耗,所以线程的资源是有限的,尤其是将多个阻塞操作拆分为多个线程的做法,就是的多个线程在空耗,浪费了服务器的性能,这就是协程适用的场景。

协程,其实就是在一个线程中,有一个总调度器,对于多个任务,同时只有一个任务在执行,但是一旦该任务进入阻塞态,则将该任务设置为挂起,运行其他任务,在运行完或者挂起其他任务的时候,再检查待运行或者挂起的任务的状态,使其继续执行。

java并不原生支持协程,框架 quasar 便是为此而生。所谓原生支持,就是在语法级别支持执行某个语句的时候就进入挂起态,然后有统一的调度中心去调度。quasar便是为java做了这个工作。

协程以及quasar原理

下面我们介绍一下quasar的原理。想象一下场景,有一段代码可能阻塞,我们希望运行到这个地方的时候可以交由协程控制,一旦进入阻塞状态,就进行挂起,在阻塞完成后再运行,那我们就需要保存运行的上下文环境。

如果我们希望某一个代码范围内产生的任务通过协程去做,这里面会有多个阻塞的任务。而不同的阻塞任务里,还会有多个不同的阻塞代码。如果协程调度中心仅仅对里面真正阻塞的地方进行调度,那么该阻塞代码被调度进去后,后面的代码直接继续进行也是不合适的,所以最顶上要有一个安全的可以支持直接调度,执行后面代码的代码,也就是我所谓的最顶端多任务的地方。

这就涉及到任务的衍生关系,在子任务调度运行成功后才能运行外边的大阻塞任务代码块,这个很类似java进程池的ForkJoinPool,一个任务里可以fork出其他任务,而该任务挂起后,其再次触发需要在其子任务都执行完成之后。

quassar正是这么做的,在运行过程中它会将会阻塞的任务交给调度中心去执行,调度中心维护好这些有fork关系的任务的上下文。那么它是怎么知道哪些方法需要suspend的呢?

quasar 会对我们的代码进行static call-site分析,在必要的地方织入用于保存和恢复调用栈的代码。
哪些方法需要call site分析?这里需要显式的mark(jdk9不需要),如下

  • 方法带有Suspendable 注解

  • 方法带有SuspendExecution

  • 方法为classpath下/META-INF/suspendables、/META-INF/suspendable-supers指定的类或接口,或子类
    符合上面条件的method,quasar会对其做call site分析,也许为了效率,quasar并没有对所有方法做call site分析

方法内哪些指令需要instrument(在其前后织入相关指令)?

  • 调用方法带有Suspendable 注解

  • 调用方法带有SuspendExecution

  • 调用方法为classpath下/META-INF/suspendables、/META-INF/suspendable-supers指定的类或接口,或子类
    主要为了解决第三方库无法添加Suspendable注解的问题

  • 通过反射调用的方法

  • 动态方法调用 MethodHandle.invoke

  • Java动态代理InvocationHandler.invoke

  • Java 8 lambdas调用

注意,instrument是在class loading的时候,不是runtime,所以这里call site分析的比如virtual invoke指令是编译期决定的,这里比较容易犯错,我总结了如下两点
1.基于接口或基类编译的代码,如果实现类有可能suspend,那么需要在接口或基类中添加suspendable annotation或suspend异常
2.如果实现类会suspend,需要添加suspendable annotation或suspend异常,当然可以把所有实现类都声明成suspendable(如果方法里找不到suspend的调用,该方法将不被instrument,所以也没有overhead,尽管这个overhead非常微小)

这一切是通过修改class字节码来实现的,具体的在后文介绍。

快速上手 

git clone https://github.com/puniverse/quasar-mvn-archetype
cd quasar-mvn-archetype
mvn install
cd ..

#快速产生一个maven工程
mvn archetype:generate -DarchetypeGroupId=co.paralleluniverse -DarchetypeArtifactId=quasar-mvn-archetype -DarchetypeVersion=0.7.4 -DgroupId=testgrp -DartifactId=testprj

#运行该maven项目,演示协程效果
cd testprj
mvn test
mvn clean compile dependency:properties exec:exec

上两步好理解,第三步做了哪些事情呢?

关键在compile上,下面结合pom.xml进行分析:

 <build>
        <finalName>unitymob-proxy</finalName>
        <plugins>
        
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.2</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>

  

            <!-- Collecting classpath entries as properties in the form groupId:artifactId:type:[classifier]
                 as per http://maven.apache.org/plugins/maven-dependency-plugin/properties-mojo.html -->
            <plugin>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>2.5.1</version>
                <executions>
                    <execution>
                        <id>getClasspathFilenames</id>
                        <goals>
                            <goal>properties</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId> <!-- Run with "mvn compile maven-dependency-plugin:properties exec:exec" -->
                <version>1.3.2</version>
                <configuration>
                    <mainClass>testgrp.QuasarIncreasingEchoApp</mainClass>
                    <workingDirectory>target/classes</workingDirectory>
                    <executable>java</executable>
                    <arguments>
                        <!-- Debug -->
                        <argument>-Xdebug</argument>
                        <!-- argument>-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005<argument -->

                        <!-- Configure memory settings as needed -->
                        <!-- argument>-Xmx1000m</argument -->

                        <!--
                            Agent-based runtime instrumentation; use _either_ AoT or agent-based, not both
                        -->

                        <!-- Turn off before production -->
                        <argument>-Dco.paralleluniverse.fibers.verifyInstrumentation=true</argument>
                        <argument>-Dco.paralleluniverse.fibers.allowJdkInstrumentation=true</argument>
                        <!--<argument>-Dco.paralleluniverse.fibers.detectRunawayFibers=true</argument>-->

                        <!-- Enable if using compile-time (AoT) instrumentation -->
                        <!-- argument>-Dco.paralleluniverse.fibers.disableAgentWarning</argument -->

                        <!-- Quasar Agent for JDK 7 -->
                        <!-- argument>-javaagent:${co.paralleluniverse:quasar-core:jar}</argument-->

                        <!-- Quasar Agent for JDK 8 -->
                        <argument>-javaagent:${co.paralleluniverse:quasar-core:jar:jdk8}</argument> <!-- Add "=b" to force instrumenting blocking calls like Thread.sleep() -->

                        <!-- Classpath -->
                        <argument>-classpath</argument>
                        <classpath/>

                        <!-- Main class -->
                        <argument>testgrp.QuasarIncreasingEchoApp</argument>
                    </arguments>
                </configuration>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.9</version>
                <configuration>
                    <!-- Debug --> <!-- -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005 -->

                    <argLine>-Xdebug</argLine>
                    <!-- Configure memory settings as needed -->
                    <!-- argLine>-Xmx1000m</argLine -->

                    <!--
                        Agent-based runtime instrumentation, for tests; use _either_ AoT or agent-based, not both
                    -->

                    <!-- Turn off before production -->
                    <argLine>-Dco.paralleluniverse.fibers.verifyInstrumentation=true</argLine>
                    <!--<argLine>-Dco.paralleluniverse.fibers.detectRunawayFibers=true</argLine>-->
                    <argLine>-Dco.paralleluniverse.fibers.allowJdkInstrumentation=true</argLine>

                    <!-- Enable if using compile-time (AoT) instrumentation -->
                    <!-- argLine>-Dco.paralleluniverse.fibers.disableAgentWarning</argLine -->

                    <!-- Quasar Agent for JDK 7 -->
                    <!-- argLine>-javaagent:${co.paralleluniverse:quasar-core:jar}</argLine-->

                    <!-- Quasar Agent for JDK 8 -->
                    <argLine>-javaagent:${co.paralleluniverse:quasar-core:jar:jdk8}</argLine>
                </configuration>
            </plugin>
        </plugins>
    </build>

testgrp.QuasarIncreasingEchoApp是要运行的类,也是要用来测试协程的代码

<argments>是jvm参数,指定了co.paralleluniverse:quasar-core:jar作为agent。javaagent是一种在不影响正常编译的情况下,修改字节码的技术。quassar在agent中进行了instrument操作,也就是上文所说的修改class文件的字节码,使其支持在操作前保存上下文以及重入(恢复)。

另一种方案是事先修改好字节码,比如在compile的时候

<plugin>
                <groupId>com.vlkan</groupId>
                <artifactId>quasar-maven-plugin</artifactId>
                <version>0.7.3</version>
                <configuration>
                    <check>true</check>
                    <debug>true</debug>
                    <verbose>true</verbose>
                </configuration>
                <executions>
                    <execution>
                        <phase>compile</phase>
                        <goals>
                            <goal>instrument</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

编译好后就可以直接在target下通过反编译看修改后的java代码。如果仅仅看编译效果可以运行:mvn compile dependency:properties

代码层分析

样例代码

package testgrp;

import java.util.concurrent.ExecutionException;

import co.paralleluniverse.strands.SuspendableCallable;
import co.paralleluniverse.strands.SuspendableRunnable;
import co.paralleluniverse.strands.channels.Channels;
import co.paralleluniverse.strands.channels.IntChannel;

import co.paralleluniverse.fibers.Fiber;
import co.paralleluniverse.fibers.SuspendExecution;

/**
 * Increasing-Echo Quasar Example
 *
 * @author circlespainter
 */
public class QuasarIncreasingEchoApp {
    static public Integer doAll() throws ExecutionException, InterruptedException {
        final IntChannel increasingToEcho = Channels.newIntChannel(0); // Synchronizing channel (buffer = 0)
        final IntChannel echoToIncreasing = Channels.newIntChannel(0); // Synchronizing channel (buffer = 0)

        Fiber<Integer> increasing = new Fiber<>("INCREASER", new SuspendableCallable<Integer>() { @Override public Integer run() throws SuspendExecution, InterruptedException {
            // The following is enough to test instrumentation of synchronizing methods
            // synchronized(new Object()) {}

            int curr = 0;
            for (int i = 0; i < 10 ; i++) {
                Fiber.sleep(10);
                System.out.println("INCREASER sending: " + curr);
                increasingToEcho.send(curr);
                curr = echoToIncreasing.receive();
                System.out.println("INCREASER received: " + curr);
                curr++;
                System.out.println("INCREASER now: " + curr);
            }
            System.out.println("INCREASER closing channel and exiting");
            increasingToEcho.close();
            return curr;
        } }).start();

        Fiber<Void> echo = new Fiber<Void>("ECHO", new SuspendableRunnable() { @Override public void run() throws SuspendExecution, InterruptedException {
            Integer curr;
            while (true) {
                Fiber.sleep(1000);
                curr = increasingToEcho.receive();
                System.out.println("ECHO received: " + curr);

                if (curr != null) {
                    System.out.println("ECHO sending: " + curr);
                    echoToIncreasing.send(curr);
                } else {
                    System.out.println("ECHO detected closed channel, closing and exiting");
                    echoToIncreasing.close();
                    return;
                }
            }
        } }).start();

        try {
            increasing.join();
            echo.join();
        } catch (ExecutionException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        return increasing.get();
    }

    static public void main(String[] args) throws ExecutionException, InterruptedException {
        doAll();
    }
}

通过Fiber类来将该任务的运行交给协程调度,在其中阻塞的地方比如receive以及send处,就会进行上下文的保存操作。Channel是一种fiber间通信,也可以说帮助quassar调度的通道,可以在发出消息后,通知等待的fiber运行。

compile之后被quassar修改的代码如下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package testgrp;

import co.paralleluniverse.fibers.Fiber;
import co.paralleluniverse.fibers.Instrumented;
import co.paralleluniverse.fibers.SuspendExecution;
import co.paralleluniverse.strands.SuspendableCallable;
import co.paralleluniverse.strands.SuspendableRunnable;
import co.paralleluniverse.strands.channels.Channels;
import co.paralleluniverse.strands.channels.IntChannel;
import java.util.concurrent.ExecutionException;

public class QuasarIncreasingEchoApp {
    public QuasarIncreasingEchoApp() {
    }

    public static Integer doAll() throws ExecutionException, InterruptedException {
        final IntChannel increasingToEcho = Channels.newIntChannel(0);
        final IntChannel echoToIncreasing = Channels.newIntChannel(0);
        Fiber<Integer> increasing = (new Fiber("INCREASER", new SuspendableCallable<Integer>() {
            @Instrumented(
                suspendableCallSites = {29, 31, 32},
                methodStart = 27,
                methodEnd = 39,
                methodOptimized = false
            )
            public Integer run() throws SuspendExecution, InterruptedException {
                // $FF: Couldn't be decompiled
            }
        })).start();
        Fiber echo = (new Fiber("ECHO", new SuspendableRunnable() {
            @Instrumented(
                suspendableCallSites = {45, 46, 51},
                methodStart = 45,
                methodEnd = 55,
                methodOptimized = false
            )
            public void run() throws SuspendExecution, InterruptedException {
                // $FF: Couldn't be decompiled
            }
        })).start();

        try {
            increasing.join();
            echo.join();
        } catch (ExecutionException var5) {
            var5.printStackTrace();
        } catch (InterruptedException var6) {
            var6.printStackTrace();
        }

        return (Integer)increasing.get();
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        doAll();
    }
}

运行结果如下:

INCREASER sending: 0
ECHO received: 0
ECHO sending: 0
INCREASER received: 0
INCREASER now: 1
INCREASER sending: 1
ECHO received: 1
ECHO sending: 1
INCREASER received: 1
INCREASER now: 2
INCREASER sending: 2
ECHO received: 2
ECHO sending: 2
INCREASER received: 2
...
INCREASER now: 10
INCREASER closing channel and exiting
ECHO received: null
ECHO detected closed channel, closing and exiting

进一步地,这些// $FF: Couldn't be decompiled 的代码,也就是真正执行堆栈操作以及代码执行流控制的是怎样的呢?这里我们通过jad反编译如下java文件

static void doAll1() throws SuspendExecution, InterruptedException{
        for(int i=0;i<2;i++){
            doAll2();
            System.out.println("add");
        }
    }

    static void doAll2() throws SuspendExecution, InterruptedException{

        for(int i=0;i<2;i++){
            System.out.println("dss");
        }
    }


    static public void main(String[] args) throws SuspendExecution,ExecutionException, InterruptedException {
        new Fiber<Void>(new SuspendableRunnable() {
            @Override
            public void run() throws SuspendExecution, InterruptedException {
                for(int i=0;i<2;i++){
                    doAll1();
                    System.out.println("add");
                }
            }
        }).start();
    }

生成:

static void doAll1()
        throws SuspendExecution, InterruptedException
    {
        Object obj = null;
        Stack stack;
        if((stack = Stack.getStack()) == null) goto _L2; else goto _L1
_L1:
        boolean flag = true;
        stack.nextMethodEntry();
        JVM INSTR tableswitch 1 1: default 36
    //                   1 72;
           goto _L3 _L4
_L3:
        if(!stack.isFirstInStackOrPushed())
            stack = null;
_L2:
        int i;
        boolean flag1 = false;
        i = 0;
_L9:
        if(i >= 2) goto _L6; else goto _L5
_L5:
        if(stack == null) goto _L8; else goto _L7
_L7:
        stack.pushMethod(1, 1);
        Stack.push(i, stack, 0);
        boolean flag2 = false;
_L4:
        i = stack.getInt(0);
_L8:
        doAll2();
        System.out.println("add");
        i++;
          goto _L9
_L6:
        if(stack != null)
            stack.popMethod();
        return;
        if(stack != null)
            stack.popMethod();
        throw ;
    }

    static void doAll2()
        throws SuspendExecution, InterruptedException
    {
        for(int i = 0; i < 2; i++)
            System.out.println("dss");

    }

    public static void main(String args[])
        throws SuspendExecution, ExecutionException, InterruptedException
    {
        (new Fiber(new SuspendableRunnable() {

            public void run()
                throws SuspendExecution, InterruptedException
            {
                Object obj = null;
                Stack stack;
                if((stack = Stack.getStack()) == null) goto _L2; else goto _L1
_L1:
                boolean flag = true;
                stack.nextMethodEntry();
                JVM INSTR tableswitch 1 1: default 36
            //                           1 72;
                   goto _L3 _L4
_L3:
                if(!stack.isFirstInStackOrPushed())
                    stack = null;
_L2:
                int i;
                boolean flag1 = false;
                i = 0;
_L9:
                if(i >= 2) goto _L6; else goto _L5
_L5:
                if(stack == null) goto _L8; else goto _L7
_L7:
                stack.pushMethod(1, 1);
                Stack.push(i, stack, 0);
                boolean flag2 = false;
_L4:
                i = stack.getInt(0);
_L8:
                QuasarIncreasingEchoApp.doAll1();
                System.out.println("add");
                i++;
                  goto _L9
_L6:
                if(stack != null)
                    stack.popMethod();
                return;
                if(stack != null)
                    stack.popMethod();
                throw ;
            }

        }
)).start();
    }

Method Instrument实现细节

这里是整个Quasar Fiber是实现原理中最为关键的地方,也是大家疑问最多的地方,大家有兴趣可以看下源代码,大概1000多行的ASM操作,既可以巩固JVM知识又能深入原理理解Fiber,这里我不打算引入过多ASM的知识,主要从实现逻辑上进行介绍

InstrumentClass 继承ASM的ClassVisitor,对Suspendable的方法前后进行织入
InstrumentClass visitEnd中会创建InstrumentMethod,具体织入的指令在InstrumentMethod中处理
结合上面的instrument示例代码图,不妨先思考几个问题

  • 怎么找到suspend call

  • 怎么保存、恢复局部变量,栈帧等

  • switch case跳转如何织入

  • suspend call在try catch块中,如何处理

  • 什么情况下在suspend call前后可以不织入也能正常运行

1.怎么找到suspend call
InstrumentMethod.callsSuspendables这个方法会遍历方法的instructions,
如果instruction是method invoke,则判断是否为suspend call(判断逻辑见上面章节)
如果instruction为suspend call,则把instrunction序号和source line number分别纪录到suspCallsBcis及suspCallsSourceLines这两个数组,供后面逻辑使用

2.switch case跳转织入是如何实现的
现在我们知道了怎么找到method中的suspend call,那如何把这些suspend calls拆分成instrument示例图中那样呢(switch case,pc...)
这个拆分过程在InstrumentMethod.collectCodeBlocks
根据上面计算的suspend call的数组,分配label数组,然后根据pc计数器(详细见后续章节)进行跳转label
label是JVM里用于jump类指令,如(GOTO,IFEQ,TABLESWITCH等)
quasar会把织入的上下文保存恢复指令及代码原始的指令生成到对应label

3.怎么保存、恢复局部变量,栈帧

 
  1. - 在方法开始执行

  2. 1.调用Stack.nextMethodEntry,开启新的method frame

  3.  
  4. - 在方法结束执行

  5. 1.Stack.popMethod, 进行出栈

  6.  
  7. - 在调用Suspendable方法之前,增加以下逻辑

  8. 1.调用Stack.pushMethod 保存栈帧信息

  9. 2.依次调用Stack.put保存操作数栈数据

  10. 3.依次调用Stack.put保存局部变量

  11.  
  12. - 在Suspendable方法调用后

  13. 1.依次调用Stack.get恢复局部变量

  14. 2.依次调用Stack.get恢复操作数栈

  15. 恢复局部变量和操作数栈的区别是前者在get后调用istore

  16.  

因为Stack.put有3个参数,所以这里每个put其实是多条jvm指令

 
  1. aload_x //如果是保存操作数栈,这条指令不需要,因为值已经在操作数栈了

  2. aload_x //load Stack引用

  3. iconst_x //load Stack idx

  4. invokestatic co/paralleluniverse/fibers/Stack:push (Ljava/lang/Object;Lco/paralleluniverse/fibers/Stack;I)V

 
  1. /**

  2. Stack.put会根据不同类型进行处理,Object或Array保存到dataObject[],其他保存到dataLong[]

  3. **/

  4. public static void push(long value, Stack s, int idx)

  5. public static void push(float value, Stack s, int idx)

  6. public static void push(double value, Stack s, int idx)

  7. public static void push(Object value, Stack s, int idx)

  8. public static void push(int value, Stack s, int idx)

java编译期可知局部变量表和操作数栈个数,上面put或get依赖这些信息,Stack具体逻辑见后面章节

4.什么情况下在suspend call前后可以不织入也能正常运行 
这里其实是一个优化,就是如果method内部只有一个suspend call,且前后没有如下指令

  • side effects,包括方法调用,属性设置

  • 向前jump

  • monitor enter/exit

那么,quasar并不会对其instrument,也就不需要collectCodeBlocks分析,因为不需要保存、恢复局部变量

5.suspend call在try catch块中,如何处理
如果suspend call在一个大的try catch中,而我们又需要在中间用switch case切分,似乎是个比较棘手的问题,
所以在织入代码前,需要对包含suspend call的try catch做切分,将suspend call单独包含在try catch当中,通过ASM MethodNode.tryCatchBlocks.add添加新try catch块,
quasar先获取MethodNode的tryCatchBlocks进行遍历,如果suspend call的指令序号在try catch块内,那么就需要切分,以便织入代码

最佳实践

用了协程,我们的编码方式上要做哪些修改?这个跟nio类似,原来用阻塞io顺序执行的代码,所有阻塞且想要让协程调度的地方都要改成Fiber的形式。最顶上的new Fiber以及start是不可避免的;该Fiber内部的阻塞的地方(suspendable的代码)quassar会帮你做好任务调度:

每个suspendable的方法就是一个continuation,是scheduler调度的单元,也是重入时的一个路径选择点 Fiber里的方法就是这样一个单元

但是同一个Fiber下,非嵌套的阻塞代码块(suspendable的代码),quassar却不能帮我们做好调度,因为它们都只是某一条执行路径,一旦程序进入等待,后面suspendable的方法不会提交给scheduler;此时就需要我们在里面再将这些可以"并行"的任务(suspendable的代码)放入不同的Fiber并start,让多个不依赖的任务“并行”运行,可重入地生产以及消费数据。

测试如下:

META-INF下的suspendables中配置:

com.unitymob.proxy.QuasarIncreasingEchoApp.test1

1、

static void doAll3(){
        new Fiber<Void>(new SuspendableRunnable() {
            @Override
            public void run() throws SuspendExecution, InterruptedException {
                new Fiber<Void>(new SuspendableRunnable() {
                    @Override
                    public void run() throws SuspendExecution, InterruptedException {
                        test1(1);
                    }
                }).start();
                test1(2);
            }
        }).start();
    }

    static void test1(int a){
        System.out.println(a);
        try {
            new URL("http://www.baidu.com").openConnection().getContent();
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(String.format("after:%s",a));
    }

运行结果如下:

1
2
after:2
after:1

2、

static void doAll3(){
        new Fiber<Void>(new SuspendableRunnable() {
            @Override
            public void run() throws SuspendExecution, InterruptedException {
//                new Fiber<Void>(new SuspendableRunnable() {
//                    @Override
//                    public void run() throws SuspendExecution, InterruptedException {
                        test1(1);
//                    }
//                }).start();
                test1(2);
            }
        }).start();
    }

    static void test1(int a){
        System.out.println(a);
        try {
            new URL("http://www.baidu.com").openConnection().getContent();
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println(String.format("after:%s",a));
    }

运行结果如下:

1

after:1
2
after:2

  • 7
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 19
    评论
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值