Linux下面的线程锁,条件变量以及信号量的使用

http://www.cppblog.com/converse/archive/2009/01/15/72064.html


一) 线程锁
1) 只能用于"锁"住临界代码区域
2) 一个线程加的锁必须由该线程解锁.

锁几乎是我们学习同步时最开始接触到的一个策略,也是最简单, 最直白的策略.

二) 条件变量,与锁不同, 条件变量用于等待某个条件被触发
1) 大体使用的伪码:

// 线程一代码
pthread_mutex_lock(&mutex);
// 设置条件为true
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);

// 线程二代码
pthread_mutex_lock(&mutex);
while (条件为false)
    pthread_cond_wait(&cond, &mutex);
修改该条件
pthread_mutex_unlock(&mutex);

需要注意几点:
1) 第二段代码之所以在pthread_cond_wait外面包含一个while循环不停测试条件是否成立的原因是, 在pthread_cond_wait被唤醒的时候可能该条件已经不成立.UNPV2对这个的描述是:"Notice that when pthread_cond_wait returns, we always test the condition again, because spurious wakeups can occur: a wakeup when the desired condition is still not true.".

2)pthread_cond_wait调用必须和某一个mutex一起调用, 这个mutex是在外部进行加锁的mutex,在调用pthread_cond_wait时, 内部的实现将首先将这个mutex解锁, 然后等待条件变量被唤醒, 如果没有被唤醒,该线程将一直休眠, 也就是说, 该线程将一直阻塞在这个pthread_cond_wait调用中, 而当此线程被唤醒时,将自动将这个mutex加锁.
man文档中对这部分的说明是:
pthread_cond_wait atomically unlocks the mutex (as per pthread_unlock_mutex) and waits for the condition variable cond to  be  signaled.  The thread execution is suspended and does not consume any CPU time until the condition variable is
signaled. The mutex must be locked by the calling thread on entrance to pthread_cond_wait.  Before  returning  to  the calling thread, pthread_cond_wait re-acquires mutex (as per pthread_lock_mutex).
也就是说pthread_cond_wait实际上可以看作是以下几个动作的合体:
解锁线程锁
等待条件为true
加锁线程锁.

这里是使用条件变量的经典例子:
http://www.cppblog.com/CppExplore/archive/2008/03/20/44949.html
之所以使用两个条件变量,是因为有两种情况需要进行保护,使用数组实现循环队列,因此一个条件是在getq函数中判断读写指针相同且可读数据计数为0,此时队列为空没有数据可读,因此获取新数据的条件变量就一直等待,另一个条件是读写指针相同且可读数据计数大于0,此时队列满了不能再添加数据,因此添加新数据的条件变量就一直等待,而nEmptyThreadNum和nFullThreadNum则是计数,只有这个计数大于0时才会唤醒相应的条件变量,这样可以减少调用pthread_cond_signal的次数.
为了在下面的叙述方便, 我将这段代码整理在下面, 是一个可以编译运行的代码,但是注意需要在编译时加上-pthread链接线程库:
#include  < pthread.h >
#include 
< stdio.h >
#include 
< unistd.h >
#include 
< stdlib.h >

class  CThreadQueue
{
public :
    CThreadQueue(
int  queueSize = 1024 ):
        sizeQueue(queueSize),lput(
0 ),lget( 0 ),nFullThread( 0 ),nEmptyThread( 0 ),nData( 0 )
    {
        pthread_mutex_init(
& mux, 0 );
        pthread_cond_init(
& condGet, 0 );
        pthread_cond_init(
& condPut, 0 );
        buffer
= new   void   * [sizeQueue];
    }
    
virtual   ~ CThreadQueue()
    {
        delete[] buffer;
    }
    
void   *  getq()
    {
        
void   * data;
        pthread_mutex_lock(
& mux);
        /*
        此处循环判断的原因如下:假设2个线程在getq阻塞,然后两者都被激活,而其中一个线程运行比较块,快速消耗了2个数据,另一个线程醒来的时候已经没有新数据可以消耗了。另一点,manpthread_cond_wait可以看到,该函数可以被信号中断返回,此时返回EINTR。为避免以上任何一点,都必须醒来后再次判断睡眠条件。更正:pthread_cond_wait是信号安全的系统调用,不会被信号中断。
        */
         while (lget == lput && nData == 0 )
        {
            nEmptyThread
++ ;
            pthread_cond_wait(
& condGet, & mux);
            nEmptyThread
-- ;     
        }

        data
= buffer[lget ++ ];
        nData
-- ;
        
if (lget == sizeQueue)
        {
            lget
= 0 ;
        }
        
if (nFullThread)  // 必要时才进行signal操作,勿总是signal
        {
            pthread_cond_signal(
& condPut);    
        }
        pthread_mutex_unlock(
& mux);
        
return  data;
    }
    
void  putq( void   * data)
    {
        pthread_mutex_lock(
& mux);
        
while (lput == lget && nData)
        { 
            nFullThread
++ ;
            pthread_cond_wait(
& condPut, & mux);
            nFullThread
-- ;
        }
        buffer[lput
++ ] = data;
        nData
++ ;
        
if (lput == sizeQueue)
        {
            lput
= 0 ;
        }
        
if (nEmptyThread) //必要时才进行signal操作,勿总是signal
        {
            pthread_cond_signal(
& condGet);
        }
        pthread_mutex_unlock(
& mux);
    }
private :
    pthread_mutex_t mux;
    pthread_cond_t condGet;
    pthread_cond_t condPut;

    
void   *   *  buffer;     // 循环消息队列
     int  sizeQueue;         // 队列大小
     int  lput;         // location put  放数据的指针偏移
     int  lget;         // location get  取数据的指针偏移
     int  nFullThread;     // 队列满,阻塞在putq处的线程数
     int  nEmptyThread;     // 队列空,阻塞在getq处的线程数
     int  nData;         // 队列中的消息个数,主要用来判断队列空还是满
};

CThreadQueue queue;
// 使用的时候给出稍大的CThreadQueue初始化参数,可以减少进入内核态的操作。

void   *  produce( void   *  arg)
{
    
int  i = 0 ;
    pthread_detach(pthread_self());
    
while (i ++< 100 )
    {
        queue.putq((
void   * )i);
    }
}

void   * consume( void   * arg)
{
    
int  data;
    
while ( 1 )
    {
        data
= ( int )(queue.getq());
        printf(
" data=%d\n " ,data);
    }
}

int  main()
{    
    pthread_t pid;
    
int  i = 0 ;

    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,produce, 0 );
    i
= 0 ;
    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,consume, 0 );
    sleep(
5 );

    
return   0 ;
}


三) 信号量
信号量既可以作为二值计数器(即0,1),也可以作为资源计数器.
主要是两个函数:
sem_wait()  decrements  (locks)  the semaphore pointed to by sem.  If the semaphore's value is greater than zero, then
the decrement proceeds, and the function returns, immediately.  If the semaphore currently has the  value  zero,  then
the  call  blocks  until  either  it  becomes possible to perform the decrement (i.e., the semaphore value rises above
zero), or a signal handler interrupts the call.

sem_post()  increments  (unlocks)  the  semaphore  pointed  to  by sem.  If the semaphore's value consequently becomes
greater than zero, then another process or thread blocked in a sem_wait(3) call will be woken up and proceed  to  lock
the semaphore.

而函数int sem_getvalue(sem_t *sem, int *sval);则用于获取信号量当前的计数.

可以用信号量模拟锁和条件变量:
1) 锁,在同一个线程内同时对某个信号量先调用sem_wait再调用sem_post, 两个函数调用其中的区域就是所要保护的临界区代码了,这个时候其实信号量是作为二值计数器来使用的.不过在此之前要初始化该信号量计数为1,见下面例子中的代码.
2)条件变量,在某个线程中调用sem_wait, 而在另一个线程中调用sem_post.

我们将上面例子中的线程锁和条件变量都改成用信号量实现以说明信号量如何模拟两者:
#include  < pthread.h >
#include 
< stdio.h >
#include 
< unistd.h >
#include 
< stdlib.h >
#include 
< fcntl.h >
#include 
< sys / stat.h >
#include 
< semaphore.h >
#include 
< errno.h >
#include 
< string .h >

class  CThreadQueue
{
public :
    CThreadQueue(
int  queueSize = 1024 ):
        sizeQueue(queueSize),lput(
0 ),lget( 0 ),nFullThread( 0 ),nEmptyThread( 0 ),nData( 0 )
    {
        
// pthread_mutex_init(&mux,0);
        mux  =  sem_open( " mutex " , O_RDWR  |  O_CREAT);
        
get   =  sem_open( " get " , O_RDWR  |  O_CREAT);
        put 
=  sem_open( " put " , O_RDWR  |  O_CREAT);
    
        sem_init(mux, 
0 1 );

        buffer
= new   void   * [sizeQueue];
    }
    
virtual   ~ CThreadQueue()
    {
        delete[] buffer;
        sem_unlink(
" mutex " );
        sem_unlink(
" get " );
        sem_unlink(
" put " );
    }
    
void   *  getq()
    {
        
void   * data;

        
// pthread_mutex_lock(&mux);
        sem_wait(mux);

        
while (lget == lput && nData == 0 )
        {
            nEmptyThread
++ ;
            
// pthread_cond_wait(&condGet,&mux);
            sem_wait( get );
            nEmptyThread
-- ;     
        }

        data
= buffer[lget ++ ];
        nData
-- ;
        
if (lget == sizeQueue)
        {
            lget
= 0 ;
        }
        
if (nFullThread)  // 必要时才进行signal操作,勿总是signal
        {
            
// pthread_cond_signal(&condPut);    
            sem_post(put);
        }

        
// pthread_mutex_unlock(&mux);
        sem_post(mux);

        
return  data;
    }
    
void  putq( void   * data)
    {
        
// pthread_mutex_lock(&mux);
        sem_wait(mux);

        
while (lput == lget && nData)
        { 
            nFullThread
++ ;
            
// pthread_cond_wait(&condPut,&mux);
            sem_wait(put);
            nFullThread
-- ;
        }
        buffer[lput
++ ] = data;
        nData
++ ;
        
if (lput == sizeQueue)
        {
            lput
= 0 ;
        }
        
if (nEmptyThread)
        {
            
// pthread_cond_signal(&condGet);
            sem_post( get );
        }

        
// pthread_mutex_unlock(&mux);
        sem_post(mux);
    }
private :
    
// pthread_mutex_t mux;
    sem_t *  mux;
    
// pthread_cond_t condGet;
    
// pthread_cond_t condPut;
    sem_t *   get ;
    sem_t
*  put;

    
void   *   *  buffer;     // 循环消息队列
     int  sizeQueue;         // 队列大小
     int  lput;         // location put  放数据的指针偏移
     int  lget;         // location get  取数据的指针偏移
     int  nFullThread;     // 队列满,阻塞在putq处的线程数
     int  nEmptyThread;     // 队列空,阻塞在getq处的线程数
     int  nData;         // 队列中的消息个数,主要用来判断队列空还是满
};

CThreadQueue queue;
// 使用的时候给出稍大的CThreadQueue初始化参数,可以减少进入内核态的操作。

void   *  produce( void   *  arg)
{
    
int  i = 0 ;
    pthread_detach(pthread_self());
    
while (i ++< 100 )
    {
        queue.putq((
void   * )i);
    }
}

void   * consume( void   * arg)
{
    
int  data;
    
while ( 1 )
    {
        data
= ( int )(queue.getq());
        printf(
" data=%d\n " ,data);
    }
}

int  main()
{    
    pthread_t pid;
    
int  i = 0 ;

    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,produce, 0 );
    i
= 0 ;
    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,consume, 0 );
    sleep(
5 );

    
return   0 ;
}


不过, 信号量除了可以作为二值计数器用于模拟线程锁和条件变量之外, 还有比它们更加强大的功能, 信号量可以用做资源计数器, 也就是说初始化信号量的值为某个资源当前可用的数量, 使用了一个之后递减, 归还了一个之后递增, 将前面的例子用资源计数器的形式再次改写如下,注意在初始化的时候要将资源计数进行初始化, 在下面代码中的构造函数中将put初始化为队列的最大数量, 而get为0:
#include  < pthread.h >
#include 
< stdio.h >
#include 
< unistd.h >
#include 
< stdlib.h >
#include 
< fcntl.h >
#include 
< sys / stat.h >
#include 
< semaphore.h >

class  CThreadQueue
{
public :
    CThreadQueue(
int  queueSize = 1024 ):
        sizeQueue(queueSize),lput(
0 ),lget( 0 )
    {
        pthread_mutex_init(
& mux, 0 );
        
get   =  sem_open( " get " , O_RDWR  |  O_CREAT);
        put 
=  sem_open( " put " , O_RDWR  |  O_CREAT);

        sem_init(
get 0 0 );
        sem_init(put, 
0 , sizeQueue);

        buffer
= new   void   * [sizeQueue];
    }
    
virtual   ~ CThreadQueue()
    {
        sem_unlink(
" get " );
        sem_unlink(
" put " );
        delete[] buffer;
    }
    
void   *  getq()
    {
        sem_wait(
get );

        
void   * data;

        pthread_mutex_lock(
& mux);

        
/*
        while(lget==lput&&nData==0)
        {
            nEmptyThread++;
            //pthread_cond_wait(&condGet,&mux);
            nEmptyThread--;     
        }
        
*/

        data
= buffer[lget ++ ];
        
// nData--;
         if (lget == sizeQueue)
        {
            lget
= 0 ;
        }
        
/*
        if(nFullThread) //必要时才进行signal操作,勿总是signal
        {
            //pthread_cond_signal(&condPut);    
            sem_post(put);
        }
        
*/
        pthread_mutex_unlock(
& mux);

        sem_post(put);

        
return  data;
    }
    
void  putq( void   * data)
    {
        sem_wait(put);

        pthread_mutex_lock(
& mux);

        
/*
        while(lput==lget&&nData)
        { 
            nFullThread++;
            //pthread_cond_wait(&condPut,&mux);
            sem_wait(put);
            nFullThread--;
        }
        
*/

        buffer[lput
++ ] = data;
        
// nData++;
         if (lput == sizeQueue)
        {
            lput
= 0 ;
        }
        
/*
        if(nEmptyThread)
        {
            //pthread_cond_signal(&condGet);
            sem_post(get);
        }
        
*/

        pthread_mutex_unlock(
& mux);

        sem_post(
get );
    }
private :
    pthread_mutex_t mux;
    
// pthread_cond_t condGet;
    
// pthread_cond_t condPut;
    sem_t *   get ;
    sem_t
*  put;

    
void   *   *  buffer;     // 循环消息队列
     int  sizeQueue;         // 队列大小
     int  lput;         // location put  放数据的指针偏移
     int  lget;         // location get  取数据的指针偏移
};

CThreadQueue queue;
// 使用的时候给出稍大的CThreadQueue初始化参数,可以减少进入内核态的操作。

void   *  produce( void   *  arg)
{
    
int  i = 0 ;
    pthread_detach(pthread_self());
    
while (i ++< 100 )
    {
        queue.putq((
void   * )i);
    }
}

void   * consume( void   * arg)
{
    
int  data;
    
while ( 1 )
    {
        data
= ( int )(queue.getq());
        printf(
" data=%d\n " ,data);
    }
}

int  main()
{    
    pthread_t pid;
    
int  i = 0 ;

    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,produce, 0 );
    i
= 0 ;
    
while (i ++< 3 )
        pthread_create(
& pid, 0 ,consume, 0 );
    sleep(
5 );

    
return   0 ;
}

可以看见,采用信号量作为资源计数之后, 代码变得"很直白",原来的一些保存队列状态的变量都不再需要了.

信号量与线程锁,条件变量相比还有以下几点不同:
1)锁必须是同一个线程获取以及释放, 否则会死锁.而条件变量和信号量则不必.

2)信号的递增与减少会被系统自动记住, 系统内部有一个计数器实现信号量,不必担心会丢失, 而唤醒一个条件变量时,如果没有相应的线程在等待该条件变量, 这次唤醒将被丢失.







本例示范Linux信号量的基本用法。该范例使用了两个线程分别对一个公用队列进行入队和出队操作,并用信号量进行控制,当队列空时出队操作可以被阻塞,当队列满时入队操作可以被阻塞。

主要用到的信号量函数有:
sem_init:初始化信号量sem_t,初始化的时候可以指定信号量的初始值,以及是否可以在多进程间共享。
sem_wait:一直阻塞等待直到信号量>0。
sem_timedwait:阻塞等待若干时间直到信号量>0。
sem_post:使信号量加1。
sem_destroy:释放信号量。和sem_init对应。
关于各函数的具体参数请用man查看。如man sem_init可查看该函数的帮助。


源代码:

 sem_t set;

 set_init(&sem,0,0);

 int i = 0;

 while(i < 100)

 {

       struct timespec ts;

       ts.tv_sec = time();

       ts.tv_nsec = 998*1000*1000;

       sem_timedwait(&sem,&ts);

 }

原意:函数循环100次,每次等待998ms。结果:函数瞬间结束

原因分析:

按照man 的解释使用sem_timedwait,用time()取时间,原以为是set_timedwait的精确性有问题,看来是冤枉它了。

time()函数返回当前绝对时间的秒级数据。

1、假设第一次循环之前绝对时间为1s990ms,则函数等待到1s998ms。等待时间为9ms。

     第二次设置时间:秒数仍为1s,而微秒级为998ms,这个时间已经在第一次循环到了,则函数不等待立即返回。

     以后98次相同。(统计以后的99次只运行了不到1ms,可见计算机的速度)

2、假设第一次循环之前绝对时间为1s999ms,则时间已经过了,函数不等到立即返回。

     可能以后的某个循环秒设置为2,再等待到998ms。不过这时等待时间已经不准确

解决方法:得到当前精确的时间,本函数可以精确到1毫秒,不过对于大部分应用已经足够了,呵呵

 sem_t set;

 set_init(&sem,0,0);

 int i = 0;

 while(i < 100)

 {

       struct timespec ts;

       struct timeval tt;

       gettimeofday(&tt,NULL);

       ts.tv_sec = tt.tv_sec;

       ts.tv_nsec = tt.tv_usec*1000 + x * 1000 * 1000;//这里可能造成纳秒>1000 000 000

       ts.tv_sec += ts.tv_nsec/(1000 * 1000 *1000);

       ts.tv_nsec %= (1000 * 1000 *1000);

       sem_timedwait(&sem,&ts);

       i++;

 }




SYNOPSIS
       #include <semaphore.h>

       int sem_init(sem_t *sem, int pshared, unsigned int value);
//初始化信号量
       int sem_wait(sem_t * sem);
//等待信号,获取拥有权
       int sem_trywait(sem_t * sem);

       int sem_post(sem_t * sem);
//发出信号即释放拥有权
       int sem_getvalue(sem_t * sem, int * sval);

       int sem_destroy(sem_t * sem);
//注销信号量,在linux中其本质是没有任何作用的,它不做任何事情。
DESCRIPTION
       This manual page documents POSIX 1003.1b semaphores, not to be confused with SystemV semaphores as described in ipc(5),
       semctl(2) and semop(2).

       Semaphores are counters for resources shared between threads. The basic operations on semaphores are: increment the
       counter atomically, and wait until the counter is non-null and decrement it atomically.
//信号量是在多线程环境中共享资源的计数器。对信号量的基本操作无非有三个:对信号量的增加;然后阻塞线程等待,直到信号量不为空才返回;然后就是对信号量的减少。
//在编程中,信号量最常用的方式就是一个线程A使用sem_wait阻塞,因为此时信号量计数为0,直到另外一个线程B发出信号post后,信号量计数加1,此时,线程A得到了信号,信号量的计数为1不为空,所以就从sem_wait返回了,然后信号量的计数又减1变为零。
       sem_init initializes the semaphore object pointed to by sem. The count associated with the semaphore is set initially to
       value. The pshared argument indicates whether the semaphore is local to the current process ( pshared is zero) or is to
       be shared between several processes ( pshared is not zero). LinuxThreads currently does not support process-shared
       semaphores, thus sem_init always returns with error ENOSYS if pshared is not zero.
//在使用信号量之前,我们必须初始化信号。第三个参数通常设置为零,初始化信号的计数为0,这样第一次使用sem_wait的时候会因为信号计数为0而等待,直到在其他地方信号量post了才返回。除非你明白你在干什么,否则不要将第三个参数设置为大于0的数。
//第二个参数是用在进程之间的数据共享标志,如果仅仅使用在当前进程中,设置为0。如果要在多个进程之间使用该信号,设置为非零。但是在Linux线程中,暂时还不支持进程之间的信号共享,所以第二个参数说了半天等于白说,必须设置为0.否则将返回ENOSYS错误。
       sem_wait suspends the calling thread until the semaphore pointed to by sem has non-zero count. It then atomically
       decreases the semaphore count.
//当信号的计数为零的时候,sem_wait将休眠挂起当前调用线程,直到信号量计数不为零。在sem_wait返回后信号量计数将自动减1.

       sem_trywait is a non-blocking variant of sem_wait. If the semaphore pointed to by sem has non-zero count, the count is
       atomically decreased and sem_trywait immediately returns 0. If the semaphore count is zero, sem_trywait immediately
       returns with error EAGAIN.
//sem_trywait是一个立即返回函数,不会因为任何事情阻塞。根据其返回值得到不同的信息。如果返回值为0,说明信号量在该函数调用之前大于0,但是调用之后会被该函数自动减1.至于调用之后是否为零则不得而知了。如果返回值为EAGAIN说明信号量计数为0。
       sem_post atomically increases the count of the semaphore pointed to by sem. This function never blocks and can safely be
       used in asynchronous signal handlers.
//解除信号量等待限制。让信号量计数加1.该函数会立即返回不等待。
       sem_getvalue stores in the location pointed to by sval the current count of the semaphore sem.
//获得当前信号量计数的值。
       sem_destroy destroys a semaphore object, freeing the resources it might hold. No threads should be waiting on the
       semaphore at the time sem_destroy is called. In the LinuxThreads implementation, no resources are associated with
       semaphore objects, thus sem_destroy actually does nothing except checking that no thread is waiting on the semaphore.
//销毁信号量对象,释放信号量内部资源。然而在linux的线程中,其实是没有任何资源关联到信号量对象需要释放的,因此在linux中,销毁信号量对象的作用仅仅是测试是否有线程因为该信号量在等待。如果函数返回0说明没有,正常注销信号量,如果返回EBUSY,说明还有线程正在等待该信号量的信号。

CANCELLATION
       sem_wait is a cancellation point.

ASYNC-SIGNAL SAFETY
       On processors supporting atomic compare-and-swap (Intel 486, Pentium and later, Alpha, PowerPC, MIPS II, Motorola 68k),
       the sem_post function is async-signal safe and can therefore be called from signal handlers. This is the only thread syn-
       chronization function provided by POSIX threads that is async-signal safe.

       On the Intel 386 and the Sparc, the current LinuxThreads implementation of sem_post is not async-signal safe by lack of
       the required atomic operations.
//现在sem_post被POSIX所规范,当它改变信号量计数器值的时候是线程安全的。

RETURN VALUE
       The sem_wait and sem_getvalue functions always return 0. All other semaphore functions return 0 on success and -1 on
       error, in addition to writing an error code in errno.
//通常返回值往往都为0,表示成功,如果有误将返回-1,并且将错误的代码号赋值给errno。

ERRORS
       The sem_init function sets errno to the following codes on error:
//sem_init失败时,常见错误有:
              EINVAL value exceeds the maximal counter value SEM_VALUE_MAX
   //第三个参数value值超过了系统能够承受的最大值SEM_VALUE_MAX.

              ENOSYS pshared is not zero
   //你将第二参数设置为了非零,如果是linux系统,请将第二个参数设置为零

       The sem_trywait function sets errno to the following error code on error:

              EAGAIN the semaphore count is currently 0

       The sem_post function sets errno to the following error code on error:

              ERANGE after incrementation, the semaphore value would exceed SEM_VALUE_MAX (the semaphore count is left unchanged
                     in this case)
   //信号量的计数超过了系统能够承受的最大值SEM_VALUE_MAX。

       The sem_destroy function sets errno to the following error code on error:

              EBUSY some threads are currently blocked waiting on the semaphore.
   //某些线程正在使用该信号量等待。

其实线程临界区可以使用信号量来实现,将信号量的信号初始化为1,然后在临界区使用完毕后再置信号量为1我们就可以轻松实现mutex了。

具体实现,自己慢慢琢磨一下吧。



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值