并发系统的测试

并发系统的测试

作者:王咏刚

1.问题引入

小时候,我既害怕吃药,又害怕打针,宁可高烧40度不退,也不愿意走进满是消毒水味儿的医院,任由医生和护士摆布。这个毛病延续到今天的后果是:自己开发的软件一旦进入测试阶段,我就会莫名其妙地紧张,有时还要张开鼻孔使劲嗅上几下,然后信誓旦旦地告诉别人“这间屋肯定洒了消毒水”,弄得对方一头雾水。

我把自己在测试时的紧张状态称为“测试综合症”。当然,患上“测试综合症”也未必是一件坏事,它至少能提醒我们:所有刚开发出来的软件都必须在“软件测试”的诊室门口排队候诊;无论是因为讳疾忌医而拒绝接受测试,还是因为爱慕虚荣而为自己的代码文过饰非,这些做法其实都和小孩子们撒泼耍赖不上医院的行为没什么两样。

言归正传。为了引出本文的案例,我们不妨换一个角度来思考问题。假如我们就是在“软件测试”的诊室里坐诊的医生,那么,对于在屋外候诊的形形色色、林林总总的软件“病人”来说,你最害怕哪一位“病人”推门而入呢?换句话说,作为软件测试者,你认为最难于测试、最不容易发现Bug 的是哪一类软件呢?

对这个问题,大多数有测试经验的人都会毫不犹豫地指出:最难测试的软件是那些有并发特性的软件系统。无论是运行在一台计算机上的多线程、多进程等多任务程序,还是部署在多个计算机节点中并发运行的分布式软件,它们的测试难度都要远远高于普通的单线程软件。这是因为,并发系统的执行序列是不可预知的,对于同样的输入,并发系统可能会产生不同的输出。这种不确定性为并发系统带来了许多与众不同的特性,比方说,并发系统可能出现以下两种特殊的故障:

l      死锁Deadlock):不同的线程或进程互相等待对方所持有的资源,以至于大家都无法继续执行。

l      竞态条件Race Condition):不同的线程或进程同时访问相同的共享资源,这可能会破坏该资源的一致性和完整性规则,从而引发共享资源的访问冲突问题。

此外,并发系统还存在活锁(Livelock)等更为复杂的错误情况,但限于篇幅,它们并不在本文的讨论对象之列。许多论述并发系统开发的书籍和文章都已明确指出了在编程中防范并发系统故障的方法。本文所要讨论的是并发系统的测试问题,即对于一个已经完成的并发系统,我们该使用何种手段进行测试,以发现系统中可能存在的死锁、竞态条件等特殊Bug 呢?

例如,下面这段Java 代码是并发系统中常见的对象池管理模块的一个缩影。对象池管理类PoolMan 使用一个布尔类型的数组pool 来模拟对象池(在实际的系统中,这个对象池可以存放远程数据库连接、可用的通信信道等不同类型的对象资源),其分配规则是,同一个对象在同一时刻只能有一个使用者。客户程序通过PoolMan 提供的getObject()方法获取对象池中的对象。当某个对象被使用时,getObject()就将pool 数组中的对应元素设为true,以防止该对象被重复使用。为了讨论上的方便,我略去了PoolMan 类中releaseObject()等其他方法的代码。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

class PoolMan

{

private boolean[] pool;

public PoolMan(int size)

{

pool = new boolean[size];

}

public int getObject()

{

int i;

synchronized (this)

{

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

}

if (i < pool.length)

{

pool[i] = true;

return i;

}

else

return -1;

}

public boolean releaseObject(int obj)

{

// . . .

}

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


PoolMan 类的代码非常简单,简单到许多有经验的程序员一眼就可以看出,在多线程的环境下,PoolMan 类的getObject()方法存在一个明显的竞态条件Bug:如果getObject()方法被多个线程同时调用,PoolMan 类就可能发生资源共享冲突方面的错误。

当然,并不是所有软件都像PoolMan 这样简单。对于那些复杂的、无法一眼就找到Bug 的并发系统,我们又该如何处置呢?究竟有几种可行的测试方法,能够在测试阶段发现与PoolMan 相似的并发系统Bug 呢?更进一步地,对于这样的测试问题,存在可用和有效的自动化测试工具吗?这些问题的确值得我们认真思考。

2 一些题外话

不同的工作过程同时并发执行,这其实是现实世界里最为普遍的一种现象。中国古人早就明确指出:“万物并育而不相害,道并行而不相悖,小德川流,大德敦化,此天地之所以为大也。”①这段话的意思是说,天地间循环往复的生息、劳作、繁衍、运动等自然和社会过程都是并发执行的,简单的系统像河水流动一样脉络分明,复杂的系统则根本盛大、枝蔓繁多,状态变化无穷无尽,正是有了这许多并发系统的存在,天地万物才那么壮观、奇伟。

毫无疑问,两千多年前的中国古人已经参透了并发系统的奥妙所在,他们真诚地告诫我们,只有“并育而不相害”、“并行而不相悖”的系统才能健康、稳定地运行。今天的科学家和程序员们显然还没有彻底领悟古人这段话的真谛。自打电子计算机诞生的那一刻起,与并发系统相关的各类问题就一直令软件开发者们头疼不已。除了本文所说的测试问题以外,进程和线程的调度、并发任务间的通信与同步、分布式系统的时钟管理、分布式资源共享、并发环境下的事务处理、并发系统的安全等诸如此类的事情始终都是软件开发领域里最繁难、最棘手的问题。

难道,这一切仅仅是因为冯·诺依曼式的计算机结构背离了现实世界的基本规则,对天地万物的并发属性做了不恰当的简化?要不然,今天的科学家为什么还在努力研究以并发和分布式为基本特征的网格计算技术?要知道,现实世界其实就是一台最大、最神奇的网格计算机呀!

3 案例分析

并发系统的测试绝不是个简单的话题。如果没有科学的理论作指导,测试者通常很难找到并发系统中的Bug。一个在软件开发领域里反复上演的场景是:并发系统的开发者们满怀信心地将经历了“严格”测试的软件安装到生产环境中,却不幸地发现,在随后的几个星期里,服务程序每隔三、四个小时就会出现一次异常,而且,每次异常的现象都各不相同。为了尽早摆脱这一场景的纠缠,我们需要在测试过程中付出更多的努力。

就拿案例中PoolMan 类的代码为例,假设我们并没有聪明到一眼就能找出代码Bug 的地步,那么,该用什么样的方法对PoolMan 类的代码进行测试呢?根据前人的经验,要找出PoolMan 类中的Bug,大抵有两类方法可以使用:一类是静态的方法,即在源代码或编译层面进行分析和测试;另一类是动态的方法,即在程序运行时测试程序的状态和属性。这两类方法各有特点,在实际应用中也的确可以起到相辅相成的功效。

我们先来谈谈静态的测试方法。最简单的静态测试方法是阅读代码。也许你并不认为阅读代码也是一种测试方法,但对于执行序列无法预知的并发系统来说,阅读系统的代码并根据已有的经验发现Bug 确实是一种非常有效的手段。在一些航空航天企业的软件开发过程中,阅读代码(有时也被称为代码“审查”或“走查”)甚至被项目管理手册列为测试过程的一个必经阶段。

如果具备了并发系统开发的相关知识,我们就不难使用阅读代码的方法发现软件系统中的竞态条件Bug。例如,在PoolMan 类的代码中,我们可以知道,当多个线程同时

访问对象池,并通过同一个PoolMan 实例的getObject()方法获取对象时,PoolMan 类的私有成员pool 就成了这些线程的共享资源。根据并发系统的开发经验,对于共享资源的访问操作应当被适当地同步,否则就有可能引发竞态条件Bug。沿着这一思路,我们可以快速阅读并查找getObject()方法中所有访问pool 的代码。

getObject()方法中,第一处访问pool 的代码是:

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

 

 

 

 


这段代码全部是对pool 数组的操作没有改变pool 数组中任何元素的取值。而且,这段代码被封装在Java语言特有的synchronized 同步结构中,在只有一个对象池的情况下,它不可能和另一个线程中相同位置的代码同时执行。根据这样的理由,我们可以简单地认为这段代码不会引发竞态条件Bug

getObject()方法中,第二处访问pool 的代码是:

if (i < pool.length)

{

pool[i] = true;

return i;

}

 

 

 

 

 

 

 

 

 


这段代码可能会改变pool 中某个元素的取值。同时,它并没有被封装到上述同步结构中。这非常清楚地提醒我们,它是一段“危险”的代码,可能导致共享资源的访问冲突。

事实也的确如此,PoolMan 类没有把为pool 赋值的操作封装在同步结构中的做法是错误的。在上面所列举的两个代码片断中,第一段代码的作用是查找pool 中未用的元素,第二段代码则是将找到的未用元素标记为“已使用”,并返回对象序号。让我们做一个假设:线程1 刚刚执行完第一段代码,发现pool 中第5 号元素的取值为false,当线程1 还没来得及执行第二段代码的时候,假设线程2 开始执行第一处代码,那么,线程2 也会发现第5 号元素处于未用状态,接下来,线程1 和线程2 分别执行第二段代码,它们会不约而同地将第5 号元素标记为true,然后返回同样的对象序号5。显然,这一结果与对象池管理程序要求每个对象同时只有一个使用者的初衷不符,PoolMan 类中的竞态条件Bug 也正在于此。

阅读代码的方法简便易行,但也存在相当多的局限:首先,通过阅读代码较难发现并发系统中的死锁Bug,因为死锁的问题并不总能在代码层面反映出来;其次,即使对于竞态条件Bug,阅读代码也不一定百分之百奏效,因为在某些情况下,对共享资源的并发访问并不会破坏共享资源的一致性和完整性,如果我们把线程中所有访问共享资源的代码都同步起来,反而会使并发系统的效率大幅降低;再次,阅读代码的效果取决于代码审查者的水平和经验,很难有一个量化的标准;最后,阅读代码并不是一种“自动化”的测试方法,而我们知道,好的测试都是自动化的测试,一个无法自动运行的测试过程是无法与增量开发、回归测试等现代软件开发技术相适应的。

为了实施更有效的静态测试,我们有必要在这里介绍一种重要的并发系统测试方法——模型检查(ModelChecking)。模型检查的基本思路是,大多数并发系统的执行过程都可以被视为一个有穷状态机,如果我们把系统的状态机模型抽象出来,并将其与并发系统必须遵循的若干规范或属性进行比较,就有可能通过自动化的方式发现系统中的Bug。——这个定义理论化太强,不大容易说明问题,我们最好还是结合实际案例来了解一下模型检查的基本原理。比如说,本文案例中PoolMan 类的getObject()方法可以被抽象为5 个独立的代码单元:

Synchronized (this)

{

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

}

If (i < pool.length)

pool[i]

return

return

 

在这里,我们简单地把这5 个代码单元看成多线程环境下独立的执行单位(暂不考虑一条Java 语句在编译后可能对应多条指令的情况)。其中,①是一个同步结构,在不同线程中不能同时执行;③、④和⑤是两个互斥的逻辑分支,在同一线程的一次方法调用中,要么执行③、④,要么执行⑤。

如果有两个线程同时执行,上述代码单元的执行顺序就存在许多种可能。我们用①.①这样的标记方法来表示第一个线程的第一个代码单元,这样,就可以将程序可能的执行顺序列举如下:

.①-①.②-①.③-①.④-②.①-②.②-②.

.①-①.②-②.①-②.②-①.③-①.④-②.③-②.

.①-②.②-②.③-②.④-①.①-①.②-①.③-①.

..

这个列表可能很长,但它是可以穷尽的。将上述列表稍微转化一下,我们就可以得到如图1 所示的一棵并发系统的“计算树(Computation Tree)”。这棵计算树显示了getObject()方法在两个线程并发的情况下,所有可能的执行路径。树中每一个矩形结点表示应用程序在执行过程中的一个特定状态,每一个箭头表示了由现有状态到新状态的一次转换,箭头旁边标注的记号则记录了引发此次状态转换的代码单元。即使对PoolMan 这样简单的代码,在只有两个线程的最简情况下,我们得到的计算树也如此庞大,以至于图1 不得不用虚线隐去了大部分的执行路径。但无论怎样,如果我们把PoolMan 看作一个有穷状态机,那么,这棵树所描绘的正是该状态机在两个线程并发时的状态图(Statechart)。

1 getObject()方法的计算树

根据上述讨论,只要我们能把并发系统抽象成由代码单元组成的执行序列,使用测试程序自动地绘制出该系统的状态图就至少在理论上具备了可行性。一旦测试程序得到了并发系统的状态图,它就可以使用状态搜索算法,沿着状态图中的每一条路径,检查系统的状态是否符合并发系统的基本属性。对系统状态的检查包括许多种不同的方法。例如,测试程序可以根据系统状态的转换绘制出不同线程为特定对象加锁的操作关系图,然后在图中搜索循环的路径,在这里,循环的路径意味着循环的锁定关系,它必将导致死锁Bug。再比如,测试程序可以根据测试者的要求,在每个状态结点检查共享资源的一致性和完整性,以寻找系统中可能存在的竞态条件Bug。当测试程序发现系统在某一条执行路径上存在状态异常时,它就会自动记录并报告Bug,这和普通测试工具发现并报告Bug 的流程大体相仿。

模型检查方法并不是一件新鲜事物。在硬件研发领域,模型检查早就取得了不俗的成绩,包括CPU 电路设计在内的许多工作都需要依靠模型检查工具来完成最终的测试或检验。在通信协议开发方面,模型检查方法也大有用武之地,因为通信协议在本质上就是发送方和接受方之间的并发协作规范,其合理性完全可以利用模型检查工具来验证。在软件测试领域,现在也有许多实用的模型检查工具可供选择。这包括简单易用的Jlinthttp://artho.com/jlint/),NASA Java PathFinder

http://ase.arc.nasa.gov/visser/jpf/),堪萨斯州立大学的Banderahttp://bandera.projects.cis.ksu.edu/),卡内基·梅隆大学的SMV

http://www-2.cs.cmu.edu/~modelcheck/smv.html)以及贝尔实验室的Spinhttp://spinroot.com/spin/whatispin.html)等等。

百闻不如一见,下面,我们不妨用著名的模型检查工具Spin 对案例中的PoolMan 类进行一次自动化的测试。根据Spin 的要求,在测试前,我们需要用一种名为PromelaPromela 是过程元语言PROcess MEta Language 的缩写)的语言把待测系统的模型(即基本的数据和操作逻辑)表达出来。尽管Bandera Java PathFinder 等工具提供了由Java 代码或可执行程序自动生成Promela 模型的功能,但为了加深读者对Spin 的印象,这里仍然用Promela 语言给出PoolMan 类的抽象描述(Promela 的语法类似于C 语言,下面的代码也基本是从案例里的Java 代码转译而来的,并不难懂):

#define SIZE 10

#define ID0 0

#define ID1 1

bool pool[SIZE]; /* 共享的对象池*/

int ret[2]; /* 用于断言检查的全局变量*/

proctype PoolMan(int id) /* PoolMan 线程*/

{

int count;

atomic /* 这相当于Java 语言中的synchronized */

{

count = 0;

do /* 寻找未用对象的循环*/

:: (count < SIZE) ->

if

:: (pool[count] == false) ->

goto done;

:: else ->

count = count + 1

fi

:: (count == SIZE) -> break

od

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

done:

if

::(count < SIZE) ->

pool[count] = true;

/* 将获得的对象序号存入全局变量*/

ret[id] = count;

/* 断言:两个线程获得的对象不能相同*/

assert(ret[ID0] != ret[ID1]) ;

:: else -> skip

fi

}

init /* 这里是程序运行的起点*/

{

/* 初始化全局变量*/

ret[ID0] = SIZE; ret[ID1] = SIZE;

/* 启动线程1 */

run PoolMan(ID0);

/* 启动线程2 */

run PoolMan (ID1)

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


在上面的Promela 代码中,我们使用断言(assert)来检查对象池的状态是否符合对象分配的基本规则。用Spin打开这段代码,执行Spin 提供的验证(Verification)功能,不一会儿,Spin 就会报出这样的错误信息:

pan: assertion violated (ret[0]!=ret[1]) (at depth18)

pan: wrote pan_in.trail

(Spin Version 4.1.1 -- 2 January 2004)

Warning: Search not completed

+ Partial Order Reduction

上述信息表明,在系统状态图第18 层的某个位置,代码中的断言不能成立,两个线程所获得的是同一个对象。也就是说,Spin 自动找到了PoolMan 类中的竞态条件Bug。接下来,我们还可以使用Spin 提供的命令行工具或图形界面程序,显示系统状态图中的错误路径。例如,Spin 可以将出现竞态条件Bug 时,PoolMan 所经历的18 次状态转换显示为:

1: proc 0 (:init:) line 38 "pan_in" (state 1)

[ret[0] = 10]

2: proc 0 (:init:) line 38 "pan_in" (state 2)

[ret[1] = 10]

3: proc 0 (:init:) line 40 "pan_in" (state 3)

[(run PoolMan(0))]

4: proc 0 (:init:) line 42 "pan_in" (state 4)

[(run PoolMan(1))]

5: proc 2 (PoolMan) line 12 "pan_in" (state 1)

[count = 0]

6: proc 2 (PoolMan) line 14 "pan_in" (state 2)

[((count<10))]

7: proc 2 (PoolMan) line 16 "pan_in" (state 3)

[((pool[count]==0))] <merge 0 now @21>

8: proc 2 (PoolMan) line 26 "pan_in" (state 15)

[((count<10))]

9: proc 1 (PoolMan) line 12 "pan_in" (state 1)

[count = 0]

10: proc 1 (PoolMan) line 14 "pan_in" (state 2)

[((count<10))]

11: proc 1 (PoolMan) line 16 "pan_in" (state 3)

[((pool[count]==0))] <merge 0 now @21>

12: proc 1 (PoolMan) line 26 "pan_in" (state 15)

[((count<10))]

13: proc 2 (PoolMan) line 27 "pan_in" (state 16)

[pool[count] = 1]

14: proc 2 (PoolMan) line 29 "pan_in" (state 17)

[ret[id] = count]

15: proc 2 (PoolMan) line 31 "pan_in" (state 18)

[assert((ret[0]!=ret[1]))]

16: proc 2 terminates

17: proc 1 (PoolMan) line 27 "pan_in" (state 16)

[pool[count] = 1]

18: proc 1 (PoolMan) line 29 "pan_in" (state 17)

[ret[id] = count]

spin: line 31 "pan_in", Error: assertion violated

spin: text of failed assertion:

assert((ret[0]!=ret[1]))

#processes: 2

19: proc 1 (PoolMan) line 31 "pan_in" (state 18)

19: proc 0 (:init:) line 43 "pan_in" (state 5)

3 processes created

这样一来,在Spin 的帮助下,测试者不但可以寻找并发系统中可能存在的Bug,而且可以根据错误路径找出重现Bug 的方法,并发现Bug 在代码中的位置(有关Spin使用方法的详细说明请参考Spin 的联机文档)。

现在,我们已经大致了解了模型检查工具的操作过程。毋庸讳言,模型检查也有一些局限性,其中最重要的一点是模型检查面对“状态爆炸(State Explosion)”问题时的尴尬和无奈。我们已经知道,即使是像PoolMan 这样简单的系统模型,其状态图已经非常庞大了。对于任何真实的系统,在几十个线程并发的时候,系统状态图的结点数目肯定是一个天文数字。模型检查工具大都无法在有限的资源条件下穷尽真实系统的所有状态。因此,许多模型检查工具都提供了简化系统模型或简化状态图的算法和工具——但这样的操作需要测试者具备较多的理论知识和实践经验,实施起来并不那么容易。

在某些时候,动态的测试方法可以弥补静态方法的不足。动态方法的核心思想是,让编译后的并发系统在预先设定的环境和输入条件下执行,在执行过程中监测并发系统的状态转换和属性特征,并通过断言、日志等方式捕获Bug。常见的动态测试方法包括:根据对系统模型和状态图的分析,预先设计系统的输入条件,以使系统按预定的状态转换路径执行;在系统代码中插入同步点,根据测试者的要求控制系统的执行路径;使用随机的方式选择系统的执行路径,并在系统代码中插入断言等检测代码;等等。这些动态方法关心的是在特定情况下系统的运行过程,一般不需要在整个系统状态图中进行费时费力的穷举搜索。——顺便说一句,普通程序员最熟悉的,让系统在实际环境中试运行几天的测试方法也是一种最原始的动态测试,但这种测试缺乏理论的指导,是盲目和低效的。

人们已经设计出了许多专用于并发系统的动态测试工具。事实上,前面提到的,包括Spin 在内的不少静态测试工具为了提高测试效率,也已嵌入了若干动态测试功能。此外, 像贝尔实验室开发的Verisofthttp://cm.bell-labs.com/who/god/verisoft/index.html),HP公司的VisualThreadshttp://www.hp.com/go/visualthreads)等都是非常著名的动态测试工具。

如果我们对并发系统的状态转换关系有足够的了解,那么,自己动手开发简单的动态测试工具也不是什么难事。例如,如果在单CPU 计算机上运行案例中提到的PoolMan程序,因为每个线程的执行周期很短,线程之间较难发生执行序列上的交叉,我们通常无法看到错误的运行结果。但如果我们已经意识到,不同线程对pool 数组的并发访问可能会导致系统故障,那么,我们完全可以在getObject()方法中加入同步代码,以强制系统进入特殊的执行路径,激活可能存在的Bug

我自己用Java 语言实现了一个非常简单的同步测试工具contestcontest 包括两个基本的Java 类:TestEvent类用于定义同步事件,TestManager 类用于管理同步测试过程。测试者首先通过TestManager 类的getInstance()方法获得TestManager 类惟一的对象实例,然后用addThread()方法向其中添加两个或多个待测线程(具体的线程代码需要测试者自己实现),接下来,测试者可以用addEvent()方法配置测试过程中的同步事件和每个同步事件的激活时间。随后,测试者在自己的线程代码中,使用TestManager 类提供的一组名为waitFor 的静态方法,强制线程在适当的位置等待某个特定的事件。

比如说,为了测试PoolMan 类的getObject()方法,我们可以在getObject()方法两次访问pool 数组的过程中加入一个同步点。改造后的getObject()方法如下所示:

public int getObject()

{

int i;

synchronized (this)

{

for (i = 0; i < pool.length; i++)

if (!pool[i])

break;

}

// 等待事件Event01

TestManager.waitFor("Event01");

// 打印输出每个线程获得的对象序号

System.out.println(

Thread.currentThread().

getName() + ": " + i);

if (i < pool.length)

{

pool[i] = true;

return i;

}

else

return -1;

}

也就是说,利用contest 提供的同步功能,我们让每个线程在执行完synchronized 结构后都先停一停,等事件“Event01”被激活后,再接着执行后续的代码。

现在,我们编译执行PoolMan contest 组合后的测试程序,正如我们所料,程序的输出结果为(假定我们运行两个测试线程):

Thread1: 0

Thread2: 0

这表明,两个线程都获得了0 号对象,我们加入的同步代码让PoolMan 类在动态运行中暴露出了共享资源访问冲突的Bug。这是一次相当成功的动态测试(感兴趣的读者可以到

http://www.contextfree.net/wangyg/source/contest.html 下载contest 工具,其中包含完整的源代码、说明文档和示例程序)。

4 补充说明

本文的案例是一段Java 代码,用于动态测试的小工具contest 也是用Java 语言实现的。不过,这并不妨碍本文所讨论的话题在其他语言和平台上的适用性。大多数静态和动态的测试方法对C 语言、C++语言、Ada 语言等同样有效。根据contest 的源代码,读者也不难用C 语言或C++语言实现类似的功能。

5 总结一下

l      并发系统的测试重点是与死锁和竞态条件相关的Bug

l      并发系统的测试方法包括静态方法和动态方法两大类。

并发系统的测试过程大都可以实现自动化。

 

 

如果图片无法显示请到:http://www.testage.net  《测试员》电子杂志下载区下载查看。

欢迎您在线阅读并留下您的宝贵意见。为《测试员》杂志投稿,请发送至:webmaster@testage.net
登陆测试时代论坛:http://www1.testage.net/bbs/index.asp

 
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值