为什么excel图片会变成代码_编写整洁的并发程序非常难,简单的代码也会变成噩梦?

Brett L.Schuchert

b0b46bd37beefbc334a691e145f1ebb3.png

“对象是过程的抽象。线程是调度的抽象。”

——James O Coplien[1]

编写整洁的并发程序很难——非常难。编写在单线程中执行的代码简单得多。编写表面上看来不错、深入进去却支离破碎的多线程代码也简单。系统一旦遭受压力,这种代码就扛不住了。

本章将讨论并发编程的需求及其困难之处,并给出一些对付这些难点、编写整洁的并发代码的建议。最后,我们将讨论与测试并发代码有关的问题。

整洁的并发编程是个复杂话题,值得用一整本书来讨论。本书只做概览,并在“并发编程II”一章中提供更详细的指引。如果你只是对并发好奇,阅读本章就足够了。如果你需要更深入地理解并发,就应读完整个指引章节。

1 为什么要并发

并发是一种解耦策略。它帮助我们把做什么(目的)和何时(时机)做分解开。在单线程应用中,目的时机紧密耦合,很多时候只要查看堆栈追踪即可断定应用程序的状态。调试这种系统的程序员可以设定断点或者断点序列,通过查看到达哪个断点来了解系统状态。

解耦目的时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易于被理解,给出了许多切分关注面的有力手段。

例如,Web应用的Servlet标准模式。这类系统运行于Web或EJB容器的保护伞之下,Web或EJB为你部分地处理并发问题。当有Web请求时,servlet就会异步执行。Servlet程序员无需管理所有的请求。原则上,每次servlet是在自己的小世界中执行,与其他servlet的执行是分离的。

当然,如果只是那么简单,也就没必要写这一章了。实际上,Web容器提供的解耦手段离完美还差得远。Servlet程序员得非常警惕、非常小心地保证并发程序不出错。同样,servlet模式的结构性好处还是很明显。

但结构并非采用并发的唯一动机。有些系统对响应时间和吞吐量有要求,需要手工编写并发解决方案。例如,考虑一个单线程信息聚合程序,它从许多Web站点获取信息,再合并写入日志中。因为该系统是单线程的,它会逐个访问Web站点,在开始下一个之前等待当前站点访问完毕。每天的执行时间必须少于24个小时。然而,随着要访问的站点越来越多,采集所有数据花费的时间也越来越多,最终超过了24个小时的限制。单线程程序许多时间花在等待Web套接字I/O结束上面。通过采用同时访问多个站点的多线程算法,就能改进性能。

或者,考虑某个每次花费1秒钟处理一个用户请求的系统。该系统在用户量较少的时候响应及时,但随着用户数增加,系统的响应时间也增加了。没人想排在150个人后面!通过并发处理多个用户请求,就能改进系统响应时间。

再或者,考虑某个解释大量数据集、但只在处理完全部数据后给出一个完整解决方案的系统。或许可以在独立的计算机上处理每个数据集,那样的话许多数据集就能并行地得到处理。

迷思与误解

看来有足够的理由采用并发方案。然而,如前文所述,并发编程很难。如果你不那么细心,就会搞出不堪入目的东西来。看看以下常见的迷思和误解:

(1)并发总能改进性能

并发有时能改进性能,但只在多个线程或处理器之间能分享大量等待时间的时候管用。事情没那么简单。

(2)编写并发程序无需修改设计

事实上,并发算法的设计有可能与单线程系统的设计极不相同。目的时机的解耦往往对系统结构产生巨大影响。

(3)在采用Web或EJB容器的时候,理解并发问题并不重要

实际上,你最好了解容器在做什么,了解如何对付本章后文将提到的并发更新、死锁等问题。

下面是一些有关编写并发软件的中肯说法:

  • 并发会在性能和编写额外代码上增加一些开销
  • 正确的并发是复杂的,即便对于简单的问题也是如此;
  • 并发缺陷并非总能重现,所以常被看做偶发事件[2]而忽略,未被当做真的缺陷看待;
  • 并发常常需要对设计策略的根本性修改

2 挑战

并发编程为何如此之难?来看看下面这个小型类:

public class X {  private int lastIdUsed;  public int getNextId() {       return ++lastIdUsed;  }}

比如,创建x的一个实体,将lastIdUsed设置为42,在两个线程中共享这个实体。假设这两个线程都调用getNextId( )方法,结果可能有三种输出:

  • 线程一得到值43,线程二得到值44,lastIdUsed为44;
  • 线程一得到值44,线程二得到值43,lastIdUsed为44;
  • 线程一得到值43,线程二得到值43,lastIdUsed为43。

第三种结果令人惊异[3],当两个线程相互影响时就会出现这种情况。这是因为线程在执行那行Java代码时有许多可能路径可行,有些路径会产生错误的结果。有多少种不同路径呢?要真正回答这个问题,需要理解Just-In-Time编译器如何对待生成的字节码,还要理解Java内存模型认为什么东西具有原子性。

简答一下,就生成的字节码而言,对于在getNextId方法中执行的那两个线程,有12870种不同的可能执行路径[4]。如果lastIdUsed的类型从int变为long,则可能路径的数量将增至2704156种。当然,多数路径都得到正确结果。问题是其中一些不能得到正确结果

3 并发防御原则

下面给出一系列防御并发代码问题的原则和技巧。

3.1  单一权责原则

单一权责原则(SRP)[5]认为,方法/类/组件应当只有一个修改的理由。并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。不幸的是,并发实现细节常常直接嵌入到其他生产代码中。下面是要考虑的一些问题:

  • 并发相关代码有自己的开发、修改和调优生命周期
  • 开发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;
  • 即便没有周边应用程序增加的负担,写得不好的并发代码可能的出错方式数量也已经足具挑战性。

建议:分离并发相关代码与其他代码[6]。

3.2 推论:限制数据作用域

如我们所见,两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。解决方案之一是采用synchronized关键字在代码中保护一块使用共享对象的临界区(critical section)。限制临界区的数量很重要。更新共享数据的地方越多,就越可能:

  • 你会忘记保护一个或多个临界区——破坏了修改共享数据的代码;
  • 得多花力气保证一切都受到有效防护(破坏了DRY原则[7]);
  • 很难找到错误源,也很难判断错误源。

建议:谨记数据封装;严格限制对可能被共享的数据的访问。

3.3 推论:使用数据复本

避免共享数据的好方法之一就是一开始就避免共享数据。在某些情形下,有可能复制对象并以只读方式对待。在另外的情况下,有可能复制对象,从多个线程收集所有复本的结果,并在单个线程中合并这些结果。

如果有避免共享数据的简易手段,结果代码就会大大减少导致错误的可能。你可能会关心创建额外对象的成本。值得试验一下看看那是否真是个问题。然而,假使使用对象复本能避免代码同步执行,则因避免了锁定而省下的价值有可能补偿得上额外的创建成本和垃圾收集开销。

3.4 推论:线程应尽可能地独立

让每个线程在自己的世界中存在,不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量。这样一来,每个线程都像是世界中的唯一线程,没有同步需要。

例如,HttpServlet的子类接收所有以参数形式传递给doGet和doPost方法的信息。每个Servlet都像拥有独立虚拟机一般运行。只要Servlet中的代码只使用本地变量,Servlet就不会导致同步问题。当然,多数使用Servlet的应用程序最终都还是会用到类似数据库连接之类的共享资源。

建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。

4 了解Java库

相对于之前的版本,Java 5提供了许多并发开发方面的改进。在用Java 5编写线程代码时,要注意以下几点:

  • 使用类库提供的线程安全群集;
  • 使用executor框架(executor framework)执行无关任务;
  • 尽可能使用非锁定解决方案;
  • 有几个类并不是线程安全的。
线程安全群集

当Java还年轻时, Doug Lea编写了Concurrent Programming in Java(中译版《Java并发编程》)教程[8],同时开发了几个线程安全群集,这些代码后来成为JDK中java.util.concurrent包的一部分。该代码包中的群集对于多线程解决方案是安全的,执行良好。实际上,在几乎所有情况下,ConcurrentHashMap实现都比HashMap表现得好。它还支持同步并发读写,也拥有支持非线程安全的合成操作的方法。如果部署环境是Java 5,可以采用ConcurrentHashMap。

还有几个支持高级并发设计的类。以下是其中一小部分,如表13-1所示。

表13-1 支持高级并发设计的类(部分)

e85555d85b41b0a1e0efa009822b60c0.png

建议:检读可用的类。对于Java,掌握java.util.concurrent、java.util.concurrent.atomic和java.util.concurrent.locks。

5 了解执行模型

有几种在并发应用中切分行为的途径。要讨论这些途径,我们需要理解一些基础定义,如表13-2所示。

表13-2 基础定义

4193a138ea5b6e2ddcc0723b23ca071f.png

有了这些定义,我们就能讨论在并发编程中用到的几种执行模型了。

5.1 生产者-消费者模型[9]

一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源

5.2 读者-作者模型[10]

当存在一个主要为读者线程提供信息源,但只偶尔被作者线程更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的累积。更新会影响吞吐量。协调读者线程,不去读作者线程正在更新的信息(反之亦然),这是一种辛苦的平衡工作。作者线程倾向于长期锁定许多读者线程,从而导致吞吐量问题。

挑战之处在于平衡读者线程和作者线程的需求,实现正确操作,提供合理的吞吐量,避免线程饥饿。

5.3 宴席哲学家[11]

想象一下,一群哲学家环坐在圆桌旁。每个哲学家的左手边放了一把叉子。桌面中央摆着一大碗意大利面。哲学家们思索良久,直至肚子饿了。每个人都要拿起叉子吃饭。但除非手上有两把叉子,否则就没法进食。如果左边或右边的哲学家已经取用一把叉子,中间这位就得等到别人吃完、放回叉子。每位哲学家吃完后,就将两把叉子放回桌面,直到肚子再饿。

用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形。如果没有用心设计,这种竞争式系统就会遭遇死锁、活锁、吞吐量和效率降低等问题。

你可能遇到的并发问题,大多数都是这三个问题的变种。请研究并使用这些算法,这样,遇到并发问题时你就能有解决问题的准备了。

建议:学习这些基础算法,理解其解决方案。

6 警惕同步方法之间的依赖

同步方法之间的依赖会导致并发代码中的狡猾缺陷。Java语言有synchronized概念,可以用来保护单个方法。然而,如果在同一共享类中有多个同步方法,系统就可能写得不太正确了[12]。

建议:避免使用一个共享对象的多个方法。

有时必须使用一个共享对象的多个方法。在这种情况发生时,有3种写对代码的手段:

  • 基于客户端的锁定——客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码;
  • 基于服务端的锁定——在服务端内创建锁定服务端的方法,调用所有方法,然后解锁。让客户端代码调用新方法;
  • 适配服务端——创建执行锁定的中间层。这是一种基于服务端的锁定的例子,但不修改原始服务端代码。

7 保持同步区域微小

关键字synchronized制造了锁。同一个锁维护的所有代码区域在任一时刻保证只有一个线程执行。锁是昂贵的,因为它们带来了延迟和额外开销。所以我们不愿将代码扔给synchronized语句了事。另一方面,临界区[13]应该被保护起来。所以,应该尽可能少地设计临界区。

有些天真的程序员想通过扩大临界区面积达到这个目的。然而,将同步延展到最小临界区范围之外,会增加资源争用、降低执行效率[14]。

建议:尽可能减小同步区域。

8  很难编写正确的关闭代码

编写永远运行的系统,与编写运行一段时间后平静地关闭的系统是两码事。

平静关闭很难做到。常见问题与死锁[15]有关,线程一直等待永远不会到来的信号。

例如,想象一个系统中有个父线程分裂出数个子线程,父线程等待所有子线程结束,然后释放资源并关闭。如果其中一个子线程发生死锁会怎样?父线程将一直等待下去,而系统就永远不能关闭。

或者,考虑一个被指示关闭的类似系统。父线程告知全体子线程放弃任务并结束。如果其中两个子线程正以生产者/消费者模型操作会怎样呢?假设生产者线程从父线程处接收到信号,并迅速关闭。消费者线程可能还在等待生产者线程发来消息,于是就被锁定在无法接收到关闭信号的状态中。它会死等生产者线程,永不结束,从而导致父线程也无法结束。

这类情形并非那么不常见。如果你要编写涉及平静关闭的并发代码,请多预留一些时间搞对关闭过程。

建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比想象中难得多。

9 测试线程代码

证明代码的正确性不切实际。测试并不能确保正确性。然而,好的测试却能尽量降低风险。这对于所有单线程解决方案都是对的。当有两个或多个线程使用同一代码段和共享数据,事情就变得非常复杂了。

建议:编写有潜力曝露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。如果测试失败,跟踪错误。别因为后来测试通过了后来的运行就忽略失败。

有一大堆问题要考虑。下面是一些精练的建议:

  • 将伪失败看作可能的线程问题;
  • 先使非线程代码可工作;
  • 编写可插拔的线程代码;
  • 编写可调整的线程代码;
  • 运行多于处理器数量的线程;
  • 在不同平台上运行;
  • 调整代码并强迫错误发生。

9.1 将伪失败看作可能的线程问题

线程代码导致“不可能失败的”失败。多数开发者缺乏有关线程如何与其他代码(可能由其他作者编写)互动的直觉。线程代码中的缺陷可能在一千或一百万次执行中才会显现一次。重复执行想要复现问题令人沮丧。所以开发者常常会将失败归咎于宇宙射线、硬件错误或其他“偶发事件”。最好假设这种偶发事件根本不存在。“偶发事件”被忽略得越久,代码就越有可能搭建于不完善的基础之上。

建议:不要将系统错误归咎于偶发事件。

9.2 先使非线程代码可工作

这看起来太浅显,但强调一下不无益处。确保线程之外的代码可工作。通常,这意味着创建由线程调用的POJO。POJO与线程无涉,所以可在线程环境之外测试。能放进POJO中的代码越多越好。

建议:不要同时追踪非线程缺陷和线程缺陷。确保代码在线程之外可工作。

9.3 编写可插拔的线程代码

编写可在数个配置环境下运行的线程代码:

  • 单线程与多个线程在执行时不同的情况;
  • 线程代码与实物或测试替身互动;
  • 用运行快速、缓慢和有变动的测试替身执行;
  • 将测试配置为能运行一定数量的迭代。

建议:编写可插拔的线程代码,这样就能在不同的配置环境下运行。

9.4 编写可调整的线程代码

要获得良好的线程平衡,常常需要试错。一开始,在不同的配置环境下监测系统性能。要允许线程数量可调整。在系统运行时允许线程发生变动。允许线程依据吞吐量和系统使用率自我调整。

9.5 运行多于处理器数量的线程

系统在切换任务时会发生一些事。为了促使任务交换的发生,运行多于处理器或处理器核心数量的线程。任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。

9.6 在不同平台上运行

2007年,我们做了一套关于并发编程的课程。该课程主要在OS X下开发,在运行于虚拟机的Windows XP上展示。用于演示的测试失败条件,在OS X上要比在XP上失败得更频繁。

被测试的代码已知是不正确的。这正强调了不同操作系统有着不同线程策略的事实,不同的线程策略影响了代码的执行。在不同环境中,多线程代码的行为也不一样[16]。应该在所有可能部署的环境中运行测试。

建议:尽早并经常地在所有目标平台上运行线程代码。

9.7 装置试错代码

并发代码中藏有缺陷,这并不罕见。简单的测试往往无法曝露这些缺陷。实际上,缺陷经常隐藏于一般处理过程中。可能好几个小时、好几天甚至好几个星期才会跳出来一次!

线程中的缺陷之所以如此不频繁、偶发、难以重现,是因为在几千个穿过脆弱区域的可能路径当中,只有少数路径会真的导致失败。经过会导致失败的路径的可能性惊人地低。所以,侦测与调试也非常之难。

怎么才能增加捕捉住如此罕见之物的机会?可以装置代码,增加对Object.wait( )、Object.sleep( )、Object.yield( )和Object.priority( )等方法的调用,改变代码执行顺序。

这些方法都会影响执行顺序,从而增加了侦测到缺陷的可能性。有问题的代码,最好尽早、尽可能多地通不过测试。

有两种装置代码的方法:

  • 硬编码;
  • 自动化。

9.8 硬编码

你可以手工向代码中插入wait( )、sleep( )、yield( )和priority( )的调用。在测试某段棘手的代码时,正当如此操作。

下面是个例子:

public synchronized String nextUrlOrNull() {  if(hasNext()) {    String url = urlGenerator.next();    Thread.yield(); // inserted for testing.    updateHasNext();    return url;  }   return null;}

插入对yield( )的调用,将改变代码的执行路径,由此而可能导致代码在以前未失败过的地方失败。如果代码的确出错,那并非是因为你插入了yield( )方法调用[17]。代码出错了,这便是失败的原因。

这种手法有许多毛病:

  • 你得手工找到合适的地方来插入方法调用;
  • 你怎么知道在哪里插入调用、插入什么调用?
  • 不必要地在产品环境中留下这类代码,将拖慢代码执行速度;
  • 这是种无的放矢的手段。你可能找不到缺陷。实际上,这不在你把握之中。

我们所需要的,是一种在测试中但不在生产中实现的手段。我们还需要为多次运行轻易地调整配置,从而增加总的发现错误机会。

无疑,如果将系统分解为对线程及控制线程的类一无所知的POJO,就能更容易地找到装置代码的位置。而且,还能创建许多个以不同方式调用sleep、yield等方法的POJO测试。

9.9 自动化

可以使用Aspect-Oriented Framework、CGLIB或ASM之类工具通过编程来装置代码。例如,可以使用有单个方法的类:

public class ThreadJigglePoint {  public static void jiggle() {  }}

可以在代码的不同位置调用这个方法:

public synchronized String nextUrlOrNull() {  if(hasNext()) {       ThreadJiglePoint.jiggle();       String url = urlGenerator.next();       ThreadJiglePoint.jiggle();       updateHasNext();       ThreadJiglePoint.jiggle();       return url;  }   return null;}

如此,你就得到了一个随机选择无所作为、睡眠或让步的方面。

或者,想象ThreadJigglePoint类有两种实现。第一种实现jiggle什么都不做,在生产环境中使用。第二种实现生成一个随机数,在睡眠、让步或径直执行间做选择。如果上千次地做这种随机测试,大概就能找到一些缺陷的根源。假如测试都通过了,至少你可以说自己已谨慎对待。这种方法看似有点过于简单,但确是替代复杂工具的一种可选方案。

有一种叫做ConTest[18]的工具,由IBM开发,能做类似的事情,但做法却稍微复杂些。

要点是让代码“异动”,从而使线程以不同次序执行。编写良好的测试与“异动”相组合,能有效地增加发现错误的机会。

建议:使用异动策略搜出错误。

10 小结

并发代码很难写正确。加入多线程和共享数据后,简单的代码也会变成噩梦。要编写并发代码,就得严格地编写整洁的代码,否则将面临微细和不频繁发生的失败。

第一要诀是遵循单一权责原则。将系统切分为分离了线程相关代码和线程无关代码的POJO。确保在测试线程相关代码时只是在测试,没有做其他事情。线程相关代码应该保持短小和目的集中。

了解并发问题的可能原因:对共享数据的多线程操作,或使用了公共资源池。类似平静关闭或停止循环之类边界情况尤其棘手。

学习类库,了解基本算法。理解类库提供的与基础算法类似的解决问题的特性。

学习如何找到必须锁定的代码区域并锁定之。不要锁定不必锁定的代码。避免从锁定区域中调用其他锁定区域。这需要深刻理解某物是否已共享。尽可能减少共享对象和共享范围。修改对象的设计,向客户代码提供共享数据,而不是迫使客户代码管理共享状态。

问题会跳出来。那种在早期没跳出来的问题往往是偶发的。这种所谓偶发问题,通常仅在高负载下出现或者偶然出现。所以,你要能在不同平台上、以不同配置持续重复运行线程代码。跟随TDD三要则而来的可测试性意味着某种程度的可插拔性,从而提供了在大量不同配置下运行代码的必要支持。

如果花点时间装置代码,就能极大地提升发现错误代码的机会。可以手工做,也可以使用某种自动化技术。尽早这么做。在将线程代码投入生产环境前,就要尽可能多地运行它。

只要采用了整洁的做法,做对的可能性就有翻天覆地的提高。

11 文献

[Lea99]:Concurrent Programming in Java: Design Principles and Patterns, 2d. ed., Doug Lea, Prentice Hall, 1999.

[PPP]:Agile Software Development: Principles, Patterns, and Practices, Robert C. Martin, Prentice Hall, 2002.

[PRAG]:The Pragmatic Programmer, Andrew Hunt, Dave Thomas, Addison-Wesley, 2000.


[1] 原注:来自私人邮件。

[2] 原注:宇宙射线、狼来了等。(译者按:作者在这里开了个小玩笑。程序员常把不能复现的程序错误的原因归结为宇宙射线等偶发性和无法修正的问题。)

[3] 原注:见后文“深入挖掘”一节。

[4] 原注:见后文“路径数量”一节。

[5] 原注:[PPP]。

[6] 原注:参见后文“客户端/服务器的例子”一节。

[7] 原注:[PRAG]。

[8] 原注:[Lea99]。

[9] 原注:http://en.wikipedia.org/wiki/Producer-consumer。

[10] 原注:http://en.wikipedia.org/wiki/Readers-writers_problem。

[11] 原注:http://en.wikipedia.org/wiki/Dining_philosophers_problem。

[12] 原注:参见后文“方法之间的依赖可能破坏同步代码”一节。

[13] 原注:临界区是为了确保程序正确而要阻止同时使用的代码区域。

[14] 原注:见后文“增加吞吐量”一节。

[15] 原注:参见附录A“死锁”一节。

[16] 原注:你是否知道,Java的线程模型并不保证线程抢先?现代操作系统支持抢先线程,所以你可以“免费”获得这一特性。即便如此,JVM也没有做出保证。

[17] 原注:严格说来并非如此。JVM不保证抢先线程,故在不抢占线程的系统上,某个特殊的算法可能一直能工作。反之亦然,但会有其他的原因影响。

[18] 原注:http://www.alphaworks.ibm.com/tech/contest。

本文摘自《代码整洁之道》

罗伯特·C.,马丁(Robert,C.,Martin) 著,韩磊 译

4d497ffcb33a5e6399428ff0ae0f9732.png
  • 鲍勃大叔作品,程序员必读,汇聚编程大师数十年编程生涯的心得体会
  • 阐释如何解决软件开发人员、项目经理及软件项目领导们所面临的棘手的问题
  • 软件开发领域经典巨著

“阅读这本书有两种原因:第一,你是个程序员;第二,你想成为更好的程序员。很好,IT行业需要更好的程序员!”——罗伯特·C. 马丁(Robert C. Martin)

本书提出一种观点:代码质量与其整洁度成正比。干净的代码,既在质量上较为可靠,也为后期维护、升级奠定了良好基础。作为编程领域的佼佼者,本书作者给出了一系列行之有效的整洁代码操作实践。这些实践在本书中体现为一条条规则(或称“启示”),并辅以来自实际项目的正、反两面的范例。只要遵循这些规则,就能编写出干净的代码,从而有效提升代码质量。

本书阅读对象为一切有志于改善代码质量的程序员及技术经理。书中介绍的规则均来自作者多年的实践经验,涵盖从命名到重构的多个编程方面,虽为一“家”之言,然诚有可资借鉴的价值。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值