多线程:C#线程同步lock,Monitor,Mutex,同步事件和等待句柄

转帖地址:

http://www.cnblogs.com/freshman0216/archive/2008/07/27/1252253.html

http://www.cnblogs.com/freshman0216/archive/2008/07/30/1252345.html

http://www.cnblogs.com/freshman0216/archive/2008/08/07/1256919.html

 

本篇从Monitor,Mutex,ManualResetEvent,AutoResetEvent,WaitHandler的类关系图开始,希 望通过本篇的介绍能对常见的线程同步方法有一个整体的认识,而对每种方式的使用细节,适用场合不会过多解释。让我们来看看这几个类的关系图:

 

      1.lock关键字

      lock是C#关键词,它将语句块标记为临界区,确保当一个线程位于代码的临界区时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。方法是获取给定对象的互斥锁,执行语句,然后释放该锁。

      MSDN上给出了使用lock时的注意事项通常,应避免锁定 public 类型,否则实例将超出代码的控制范围 。常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则。

      1)如果实例可以被公共访问,将出现 lock (this) 问题。

      2)如果 MyType 可以被公共访问,将出现 lock (typeof (MyType)) 问题由于一个类的所有实例都只有一个类型对象(该对象是typeof的返回结果),锁定它,就锁定了该对象的所有实例。微软现在建议不要使用 lock(typeof(MyType)),因为锁定类型对象是个很缓慢的过程,并且类中的其他线程、甚至在同一个应用程序域中运行的其他程序都可以访问 该类型对象,因此,它们就有可能代替您锁定类型对象,完全阻止您的执行,从而导致你自己的代码的挂起。

      3)由于进程中使用同一字符串的任何其他代码将共享同一个锁,所以出现 lock(“myLock”) 问题。这个问题和.NET Framework创建字符串的机制有关系,如果两个string变量值都是"myLock",在内存中会指向同一字符串对象。

 

      最佳做法是定义 private 对象来锁定 , 或 private static对象变量来保护所有实例所共有的数据

      我们再来通过IL Dasm看看lock关键字的本质,下面是一段简单的测试代码:

     lock  (lockobject)
    
{
        
int  i  =   5 ;
    }

 

      用IL Dasm打开编译后的文件,上面的语句块生成的IL代码为:

       IL_0045:    call        void [mscorlib]System.Threading. Monitor :: Enter (object)
      
IL_004a:    nop
      .try
      {
        
IL_004b:    nop
        
IL_004c:   ldc.i4. 5
        
IL_004d:   stloc. 1
        
IL_004e:    nop
        
IL_004f:    leave .s    IL_0059
      }  // end .try
      finally
      {
        
IL_0051:   ldloc. 3
        
IL_0052:    call        void [mscorlib]System.Threading. Monitor ::Exit(object)
        
IL_0057:    nop
        
IL_0058:   endfinally
      }  // end handler

      通过上面的代码我们很清楚的看到:lock关键字其实就是对Monitor类的Enter()和Exit()方法的封装,并通过try...catch...finally语句块确保在lock语句块结束后执行Monitor.Exit()方法,释放互斥锁。

      2.Monitor类

      Monitor类通过向单个线程授予对象锁来控制对对象的访问。对象锁提供限制访问临界区的能力。当一个线程拥有对象的锁时,其他任何 线程都不能获取该锁。还可以使用 Monitor 来确保不会允许其他任何线程访问正在由锁的所有者执行的应用程序代码节,除非另一个线程正在使用其他的锁定对象执行该代码。

      通过对lock关键字的分析我们知道,lock就是对Monitor的Enter和Exit的一个封装,而且使用起来更简洁,因此Monitor类的Enter()和Exit()方法的组合使用可以用lock关键字替代

      另外Monitor类还有几个常用的方法:

      TryEnter()能够有效的解决长期死等的问题,如果在一个并发经常发生,而且持续时间长的环境中使用TryEnter,可以有效防止死锁或者长时间 的等待。比如我们可以设置一个等待时间bool gotLock = Monitor.TryEnter(myobject,1000),让当前线程在等待1000秒后根据返回的bool值来决定是否继续下面的操作。

      Wait()释放对象上的锁以便允许其他线程锁定和访问该对象。在其他线程访问对象时,调用线程将等待。脉冲信号用于通知等待线程有关对象状态的更改。

      Pulse(),PulseAll()向一个或多个等待线程发送信号。该信号通知等待线程锁定对象的状态已更改,并且锁的所有者准备释放该锁。等待线程被 放置在对象的就绪队列中以便它可以最后接收对象锁。一旦线程拥有了锁,它就可以检查对象的新状态以查看是否达到所需状态。

      注意:Pulse、PulseAll和Wait方法必须从同步的代码块内调用。

      我们假定一种情景:妈妈做蛋糕,小孩有点馋,妈妈每做好一块就要吃掉,妈妈做好一块后,告诉小孩蛋糕已经做好了。下面的例子用Monitor类的Wait和Pulse方法模拟小孩吃蛋糕的情景。


     // 仅仅是说明Wait和Pulse/PulseAll的例子
    
// 逻辑上并不严密,使用场景也并不一定合适
     class  MonitorSample
    {
        
private   int  n  =   1 ;   // 生产者和消费者共同处理的数据
         private   int  max  =   10000 ;

        
private   object  monitor  =   new   object ();

        
public   void  Produce()
        {
            
lock  (monitor)
            {
                
for  (; n  <=  max; n ++ )
                {
                    Console.WriteLine(
" 妈妈:第 "   +  n.ToString()  +   " 块蛋糕做好了 " );
                    
// Pulse方法不用调用是因为另一个线程中用的是Wait(object,int)方法
                    
// 该方法使被阻止线程进入了同步对象的就绪队列
                    
// 是否需要脉冲激活是Wait方法一个参数和两个参数的重要区别
                    
// Monitor.Pulse(monitor);
                    
// 调用Wait方法释放对象上的锁并阻止该线程(线程状态为WaitSleepJoin)
                    
// 该线程进入到同步对象的等待队列,直到其它线程调用Pulse使该线程进入到就绪队列中
                    
// 线程进入到就绪队列中才有条件争夺同步对象的所有权
                    
// 如果没有其它线程调用Pulse/PulseAll方法,该线程不可能被执行
                    Monitor.Wait(monitor);
                }
            }
        }

        
public   void  Consume()
        {
            
lock  (monitor)
            {
                
while  ( true )
                {
                    
// 通知等待队列中的线程锁定对象状态的更改,但不会释放锁
                    
// 接收到Pulse脉冲后,线程从同步对象的等待队列移动到就绪队列中
                    
// 注意:最终能获得锁的线程并不一定是得到Pulse脉冲的线程
                    Monitor.Pulse(monitor);
                    
// 释放对象上的锁并阻止当前线程,直到它重新获取该锁
                    
// 如果指定的超时间隔已过,则线程进入就绪队列
                    Monitor.Wait(monitor, 1000 );
                    Console.WriteLine(
" 孩子:开始吃第 "   +  n.ToString()  +   " 块蛋糕 " );
                }
            }
        }

        
static   void  Main( string [] args)
        {
            MonitorSample obj 
=   new  MonitorSample();
            Thread tProduce 
=   new  Thread( new  ThreadStart(obj.Produce));
            Thread tConsume 
=   new  Thread( new  ThreadStart(obj.Consume));
            
// Start threads.
            tProduce.Start();
            tConsume.Start();

            Console.ReadLine();
        }
    }

 

      这个例子的目的是要理解Wait和Pulse如何保证线程同步的,同时要注意Wait(obeject)和Wait(object,int)方法的区别,理解它们的区别很关键的一点是要理解同步的对象包含若干引用,其中包括对当前拥有锁的线程的引用、对就绪队列(包含准备获取锁的线程)的引用和对等待队列(包含等待对象状态更改通知的线程)的引用

 

本篇继续介绍WaitHandler类及其子类Mutex,ManualResetEvent,AutoResetEvent的用法。.NET中线程同步的方式多的让人看了眼花缭乱,究竟该怎么去理解呢?其实,我们抛开.NET环境看线程同步,无非是执行两种操作:一是互斥/加锁,目的是保证临界区代码操作的“原子性”;另一种是信号灯操作,目的是保证多个线程按照一定顺序执行,如生产者线程要先于消费者线程执行。 .NET中线程同步的类无非是对这两种方式的封装,目的归根结底都可以归结为实现互斥/加锁或者是信号灯这两种方式,只是它们的适用场合有所不。下面我们根据类的层次结构了解WaitHandler及其子类。

      1.WaitHandler

      WaitHandle是Mutex,Semaphore,EventWaitHandler,AutoResetEvent,ManualResetEvent共同的祖先,它封装Win32同步句柄内核对象,也就是说是这些内核对象的托管版本。

      线程可以通过调用WaitHandler实例的方法WaitOne在单个等待句柄上阻止。此外,WaitHandler类重载了静态方法,以等待所有指 定的等待句柄都已收集到信号WaitAll,或者等待某一指定的等待句柄收集到信号WaitAny。这些方法都提供了放弃等待的超时间隔、在进入等待之前 退出同步上下文的机会,并允许其它线程使用同步上下文。WaitHandler是C#中的抽象类 ,不能实例化。

      2.EventWaitHandler vs. ManualResetEvent vs. AutoResetEvent(同步事件)

      我们先看看两个子类ManualResetEvent和AutoResetEvent在.NET Framework中的实现:


     // .NET Framework中ManualResetEvent类的实现
    [ComVisible( true ), HostProtection(SecurityAction.LinkDemand, Synchronization  =   true , ExternalThreading  =   true )]
    
public   sealed   class  ManualResetEvent : EventWaitHandle
    {
        
//  Methods
         public  ManualResetEvent( bool  initialState) :  base (initialState, EventResetMode.ManualReset)
        {
        }
    }

    
// .NET Framework中AutoResetEvent类的实现
    [ComVisible( true ), HostProtection(SecurityAction.LinkDemand, Synchronization  =   true , ExternalThreading  =   true )]
    
public   sealed   class  AutoResetEvent : EventWaitHandle
    {
        
//  Methods
         public  AutoResetEvent( bool  initialState)
            : 
base (initialState, EventResetMode.AutoReset)
        {
        }
    }


      原来ManualResetEvent和AutoResetEvent都继承自EventWaitHandler,它们的唯一区别就在于父类 EventWaitHandler的构造函数参数EventResetMode不同,这样我们只要弄清了参数EventResetMode值不同 时,EventWaitHandler类控制线程同步的行为有什么不同,两个子类也就清楚了。为了便于描述,我们不去介绍父类的两种模式,而直接介绍子 类。

      ManualResetEvent和AutoResetEvent的共同点:
      1)Set方法将事件状态设置为终止状态,允许一个或多个等待线程继续;Reset方法将事件状态设置为非终止状态,导致线程阻止;WaitOne阻止当前线程,直到当前线程的WaitHandler收到事件信号。
      2)可以通过构造函数的参数值来决定其初始状态,若为true则事件为终止状态从而使线程为非阻塞状态,为false则线程为阻塞状态。
      3)如果某个线程调用WaitOne方法,则当事件状态为终止状态时,该线程会得到信号,继续向下执行。

      ManualResetEvent和AutoResetEvent的不同点:
      1)AutoResetEvent.WaitOne()每次只允许一个线程进入,当某个线程得到信号后,AutoResetEvent会自动又将信号置为不发送状态,则其他调用WaitOne的线程只有继续等待,也就是说AutoResetEvent一次只唤醒一个线程;
      2)ManualResetEvent则可以唤醒多个线程,因为当某个线程调用了ManualResetEvent.Set()方法后,其他调用WaitOne的线程获得信号得以继续执行,而ManualResetEvent不会自动将信号置为不发送。
      3)也就是说,除非手工调用了ManualResetEvent.Reset()方法,则ManualResetEvent将一直保持有信号状态,ManualResetEvent也就可以同时唤醒多个线程继续执行。

      示例场景:张三、李四两个好朋友去餐馆吃饭,两个人点了一份宫爆鸡丁,宫爆鸡丁做好需要一段时间,张三、李四不愿傻等,都专心致志的玩 起了手机游戏,心想宫爆鸡丁做好了,服务员肯定会叫我们的。服务员上菜之后,张三李四开始享用美味的饭菜,饭菜吃光了,他们再叫服务员过来买单。我们可以 从这个场景中抽象出来三个线程,张三线程、李四线程和服务员线程,他们之间需要同步:服务员上菜—>张三、李四开始享用宫爆鸡丁—>吃好后叫 服务员过来买单。这个同步用什么呢? ManualResetEvent还是AutoResetEvent?通过上面的分析不难看出,我们应该用 ManualResetEvent进行同步,下面是程序代码:


     public   class  EventWaitTest
    {
        
private   string  name;  // 顾客姓名
        
// private static AutoResetEvent eventWait = new AutoResetEvent(false);
         private   static  ManualResetEvent eventWait  =   new  ManualResetEvent( false );
        
private   static  ManualResetEvent eventOver  =   new  ManualResetEvent( false );

        
public  EventWaitTest( string  name)
        {
            
this .name  =  name;
        }

        
public   static   void  Product()
        {
            Console.WriteLine(
" 服务员:厨师在做菜呢,两位稍等 " );
            Thread.Sleep(
2000 );
            Console.WriteLine(
" 服务员:宫爆鸡丁好了 " );
            eventWait.Set();
            
while  ( true )
            {
                
if  (eventOver.WaitOne( 1000 false ))
                {
                    Console.WriteLine(
" 服务员:两位请买单 " );
                    eventOver.Reset();
                }
            }
        }

        
public   void  Consume()
        {
            
while  ( true )
            {
                
if  (eventWait.WaitOne( 1000 false ))
                {
                    Console.WriteLine(
this .name  +   " :开始吃宫爆鸡丁 " );
                    Thread.Sleep(
2000 );
                    Console.WriteLine(
this .name  +   " :宫爆鸡丁吃光了 " );
                    eventWait.Reset();
                    eventOver.Set();
                    
break ;
                }
                
else
                {
                    Console.WriteLine(
this .name  +   " :等着上菜无聊先玩会手机游戏 " );
                }
            }
        }
    }

    
public   class  App
    {
        
public   static   void  Main( string [] args)
        {
            EventWaitTest zhangsan 
=   new  EventWaitTest( " 张三 " );
            EventWaitTest lisi 
=   new  EventWaitTest( " 李四 " );

            Thread t1 
=   new  Thread( new  ThreadStart(zhangsan.Consume));
            Thread t2 
=   new  Thread( new  ThreadStart(lisi.Consume));
            Thread t3 
=   new  Thread( new  ThreadStart(EventWaitTest.Product));

            t1.Start();
            t2.Start();
            t3.Start();

            Console.Read();          
        }
    }

   
      编译后查看运行结果,符合我们的预期,控制台输出为:
      服务员:厨师在做菜呢,两位稍等...
      张三:等着上菜无聊先玩会手机游戏
      李四:等着上菜无聊先玩会手机游戏
      张三:等着上菜无聊先玩会手机游戏
      李四:等着上菜无聊先玩会手机游戏
      服务员:宫爆鸡丁好了
      张三:开始吃宫爆鸡丁
      李四:开始吃宫爆鸡丁
      张三:宫爆鸡丁吃光了
      李四:宫爆鸡丁吃光了
      服务员:两位请买单

      如果改用AutoResetEvent进行同步呢?会出现什么样的结果?恐怕张三和李四就 要打起来了,一个享用了美味的宫爆鸡丁,另一个到要付账的时候却还在玩游戏。感兴趣的朋友可以把注释的那行代码注释去掉,并把下面一行代码注释掉,运行程 序看会出现怎样的结果。

       3.Mutex(互斥体)

       Mutex和EventWaitHandler有着共同的父类WaitHandler类,它们同步的函数用法也差不多,这里不再赘述。Mutex的突出特点是可以跨应用程序域边界对资源进行独占访问,即可以用于同步不同进程中的线程 ,这种功能当然这是以牺牲更多的系统资源为代价的。

      这种跨进程同步的一种应用是,限制同一台电脑中同时打开两个相同的程序。具体实现可以参考《用Mutex或进程限制用户在一台电脑上同时打开两个程序 》。

      参考资料:AutoResetEvent和ManualResetEvent.Net线程问题解答

 

 

前两篇简单介绍了线程同步lock,Monitor,同步事件EventWaitHandler,互斥体Mutex的基本用法,在此基础上,我们对它们用法进行比较,并给出什么时候需要锁什么时候不需要的几点建议。最后,介绍几个FCL中线程安全的类,集合类的锁定方式等,做为对线程同步系列的完善和补充。

      1.几种同步方法的区别

      lock和Monitor是.NET用一个特殊结构实现的,Monitor对象是完全托管的、完全可移植的,并且在操作系统资源要求方面可能更为有效,同步速度较快 ,但不能跨进程同步 。lock(Monitor.Enter和Monitor.Exit方法的封装),主要作用是锁定临界区,使临界区代码只能被获得锁的线程执行。Monitor.Wait和Monitor.Pulse用于线程同步,类似信号操作,个人感觉使用比较复杂,容易造成死锁。

      互斥体Mutex和事件对象EventWaitHandler属于内核对象,利用内核对象进行线程同步,线程必须要在用户模式和内核模式间切换,所以一般效率很低 ,但利用互斥对象和事件对象这样的内核对象,可以在多个进程中的各个线程间进行同步

      互斥体Mutex类似于一个接力棒,拿到接力棒的线程才可以开始跑,当然接力棒一次只属于一个线程(Thread Affinity),如果这个线程不释放接力棒(Mutex.ReleaseMutex),那么没办法,其他所有需要接力棒运行的线程都知道能等着看热闹。

      EventWaitHandle 类允许线程通过发信号互相通信 。通常,一个或多个线程在 EventWaitHandle 上阻止,直到一个未阻止的线程调用 Set 方法,以释放一个或多个被阻止的线程。

      2.什么时候需要锁定

      首先要理解锁定是解决竞争条件的,也就是多个线程同时访问某个资源,造成意想不到的结果。比如,最简单的情况是,一个计数器,两个线程 同时加一,后果就是损失了一个计数,但相当频繁的锁定又可能带来性能上的消耗,还有最可怕的情况死锁。那么什么情况下我们需要使用锁,什么情况下不需要 呢?

      1)只有共享资源才需要锁定
      只有可以被多线程访问的共享资源才需要考虑锁定,比如静态变量,再比如某些缓存中的值,而属于线程内部的变量不需要锁定。  

      2)多使用lock,少用Mutex
      如果你一定要使用锁定,请尽量不要使用内核模块的锁定机制,比如.NET的Mutex,Semaphore,AutoResetEvent和 ManuResetEvent,使用这样的机制涉及到了系统在用户模式和内核模式间的切换,性能差很多,但是他们的优点是可以跨进程同步线程,所以应该清 楚的了解到他们的不同和适用范围。

      3)了解你的程序是怎么运行的
      实际上在web开发中大多数逻辑都是在单个线程中展开的,一个请求都会在一个单独的线程中处理,其中的大部分变量都是属于这个线程的,根本没有必要考虑锁定,当然对于ASP.NET中的Application对象中的数据,我们就要考虑加锁了。

      4)把锁定交给数据库
      数据库除了存储数据之外,还有一个重要的用途就是同步,数据库本身用了一套复杂的机制来保证数据 的可靠和一致性,这就为我们节省了很多的精力。保证了数据源头上的同步,我们多数的精力就可以集中在缓存等其他一些资源的同步访问上了。通常,只有涉及到 多个线程修改数据库中同一条记录时,我们才考虑加锁。  

      5)业务逻辑对事务和线程安全的要求
      这条是最根本的东西,开发完全线程安全的程序是件很费时费力的事情,在电子商务等涉及金融系统的 案例中,许多逻辑都必须严格的线程安全,所以我们不得不牺牲一些性能,和很多的开发时间来做这方面的工作。而一般的应用中,许多情况下虽然程序有竞争的危 险,我们还是可以不使用锁定,比如有的时候计数器少一多一,对结果无伤大雅的情况下,我们就可以不用去管它。

      3.InterLocked类

      Interlocked 类提供了同步对多个线程共享的变量的访问的方法。如果该变量位于共享内存中,则不同进程的线程就可以使用该机制。互锁操作是原子的,即整个操作是不能由相 同变量上的另一个互锁操作所中断的单元。这在抢先多线程操作系统中是很重要的,在这样的操作系统中,线程可以在从某个内存地址加载值之后但是在有机会更改 和存储该值之前被挂起。

      我们来看一个InterLock.Increment ()的例子,该方法以原子的形式递增指定变量并存储结果,示例如下:


     class  InterLockedTest
    {
        
public   static  Int64 i  =   0 ;

        
public   static   void  Add()
        {
            
for  ( int  i  =   0 ; i  <   100000000 ; i ++ )
            {
                Interlocked.Increment(
ref  InterLockedTest.i);
                
// InterLockedTest.i = InterLockedTest.i + 1;
            }
        }


        
public   static   void  Main( string [] args)
        {
            Thread t1 
=   new  Thread( new  ThreadStart(InterLockedTest.Add));
            Thread t2 
=   new  Thread( new  ThreadStart(InterLockedTest.Add));

            t1.Start();
            t2.Start();

            t1.Join();
            t2.Join();

            Console.WriteLine(InterLockedTest.i.ToString());
            Console.Read();
        }
    }

 

      输出结果200000000,如果InterLockedTest.Add ()方法中用注释掉的语句代替Interlocked.Increment ()方法,结果将不可预知,每次执行结果不同。InterLockedTest.Add ()方法保证了加1操作的原子性,功能上相当于自动给加操作使用了lock锁。同时我们也注意到InterLockedTest.Add ()用时比直接用+号加1要耗时的多,所以说加锁资源损耗还是很明显的。

      另外InterLockedTest 类还有几个常用方法,具体用法可以参考MSDN上的介绍。

      4.集合类的同步

      .NET在一些集合类,比如Queue、ArrayList、HashTable和Stack,已经提供了一个供lock使用的对象SyncRoot。用Reflector查看了SyncRoot属性(Stack.SynchRoot略有不同)的源码如下:


public   virtual   object  SyncRoot
{
    
get
    {
        
if  ( this ._syncRoot  ==   null )
        {
            
// 如果_syncRoot和null相等,将new object赋值给_syncRoot
            
// Interlocked.CompareExchange方法保证多个线程在使用syncRoot时是线程安全的
            Interlocked.CompareExchange( ref   this ._syncRoot,  new   object (),  null );
        }
        
return   this ._syncRoot;
    }
}

      这里要特别注意的是MSDN提到:从头到尾对一个集合进行枚举本质上并不是一个线程安全的过程。即使一个集合已进行同步,其他线程仍可以修改该集合,这将导致枚举数引发异常。若要在枚举过程中保证线程安全,可以在整个枚举过程中锁定集合,或者捕捉由于其他线程进行的更改而引发的异常 。应该使用下面的代码:


    Queue q  =   new  Queue();
    
lock  (q.SyncRoot)
    {
        
foreach  ( object  item  in  q)
        {
            
// do something
        }
    }

      还有一点需要说明的是,集合类提供了一个是和同步相关的方法Synchronized,该 方法返回一个对应的集合类的wrapper类,该类是线程安全的,因为他的大部分方法都用lock关键字进行了同步处理。如HashTable的 Synchronized返回一个新的线程安全的HashTable实例,代码如下:


     // 在多线程环境中只要我们用下面的方式实例化HashTable就可以了
    Hashtable ht  =  Hashtable.Synchronized( new  Hashtable());

    
// 以下代码是.NET Framework Class Library实现,增加对Synchronized的认识
    [HostProtection(SecurityAction.LinkDemand, Synchronization = true )]
    
public   static  Hashtable Synchronized(Hashtable table)
    {
        
if  (table  ==   null )
        {
            
throw   new  ArgumentNullException( " table " );
        }
        
return   new  SyncHashtable(table);
    }

 
    
// SyncHashtable的几个常用方法,我们可以看到内部实现都加了lock关键字保证线程安全
     public   override   void  Add( object  key,  object  value)
    {
        
lock  ( this ._table.SyncRoot)
        {
            
this ._table.Add(key, value);
        }
    }

    
public   override   void  Clear()
    {
        
lock  ( this ._table.SyncRoot)
        {
            
this ._table.Clear();
        }
    }

    
public   override   void  Remove( object  key)
    {
        
lock  ( this ._table.SyncRoot)
        {
            
this ._table.Remove(key);
        }
    }

      线程同步是一个非常复杂的话题,这里只是根据公司的一个项目把相关的知识整理出来,作为工作的一种总结。这些同步方法的使用场景是怎样的?究竟有哪些细微的差别?还有待于进一步的学习和实践。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值