几种常用的同步方式-以Windows官网提供的程序例子进行讲解

同步(Synchronization)

原创文章,未经允许,禁止转载😤
若有说错,欢迎指正,多谢😄
资源可以被多个线程或者多个进程进行访问,而一个线程或者一个进程对资源的修改会导致另外一个线程或者另外一个进程在对其读取或者修改的时候出现数据不同步的情况,会导致不正常的程序行为,所以需要一些保护机制来保证在同一时刻只有一个线程或者一个进程对资源进行操作。

不正常的行为举例如下,以线程为例:

假设存在数据成员int i = 3;,该成员被两个线程A和B同时进行访问,在A线程的代码中,如下:

// 前面有业务逻辑代码
{
    i = 4;
	if(i==4){
		cout<<"i==4 \n";
	}else{
		cout<<"i!=4 \n";
	}
}
// 后面有业务逻辑代码

在B线程的代码中,如下:

// 前面有业务逻辑代码
{
    i = 5;
	if(i==5){
		cout<<"i==5 \n";
	}else{
		cout<<"i!=5 \n";
	}
}
// 后面有业务逻辑代码

假设在某一时间点,线程A先执行到i = 4这行代码,然后线程A失去CPU的时间片,此时线程B得到调度执行,执行了i = 5这行代码,然后线程B也立即失去了CPU的时间片,A得到调度执行,由线程A的判断分支,很明显就输出了i!=4的这个结果。对于线程A来说,我想要的肯定是i==4这个结果,由于B线程对于变量i的修改而导致A线程的程序出现的不可预知的错误。所以,需要一定的机制来保证A线程中大括号内的代码执行保证原子性,以下就引出几种机制。

Mutex

  • 基本说明:

    保护一个共享资源被多个线程或者多个进程的同时访问不会出现以上介绍出现的异常程序行为。

  • 操作对象:线程和进程。

  • 举例说明:

    如果多个线程共享对数据库的访问,那么多个线程可以共享同一个mutex对象来保证在一个连续的时间段内只能有一个线程来对数据库进行写操作(注意:两个线程或者进程只有读写、写写才会出现数据不同步的问题,读读不会)。

    在微软提供的api中,举例如下:

    #include <windows.h>
    #include <stdio.h>
    
    #define THREADCOUNT 2
    
    HANDLE ghMutex; 
    
    DWORD WINAPI WriteToDatabase( LPVOID );
    
    int main( void )
    {
        HANDLE aThread[THREADCOUNT];
        DWORD ThreadID;
        int i;
    
        // Create a mutex with no initial owner
    
        ghMutex = CreateMutex( 
            NULL,              // default security attributes
            FALSE,             // initially not owned
            NULL);             // unnamed mutex
    
        if (ghMutex == NULL) 
        {
            printf("CreateMutex error: %d\n", GetLastError());
            return 1;
        }
    
        // Create worker threads
    
        for( i=0; i < THREADCOUNT; i++ )
        {
            aThread[i] = CreateThread( 
                         NULL,       // default security attributes
                         0,          // default stack size
                         (LPTHREAD_START_ROUTINE) WriteToDatabase, 
                         NULL,       // no thread function arguments
                         0,          // default creation flags
                         &ThreadID); // receive thread identifier
    
            if( aThread[i] == NULL )
            {
                printf("CreateThread error: %d\n", GetLastError());
                return 1;
            }
        }
    
        // Wait for all threads to terminate
    
        WaitForMultipleObjects(THREADCOUNT, aThread, TRUE, INFINITE);
    
        // Close thread and mutex handles
    
        for( i=0; i < THREADCOUNT; i++ )
            CloseHandle(aThread[i]);
    
        CloseHandle(ghMutex);
    
        return 0;
    }
    
    DWORD WINAPI WriteToDatabase( LPVOID lpParam )
    { 
        // lpParam not used in this example
        UNREFERENCED_PARAMETER(lpParam);
    
        DWORD dwCount=0, dwWaitResult; 
    
        // Request ownership of mutex.
    
        while( dwCount < 20 )
        { 
            dwWaitResult = WaitForSingleObject( 
                ghMutex,    // handle to mutex
                INFINITE);  // no time-out interval
     
            switch (dwWaitResult) 
            {
                // The thread got ownership of the mutex
                case WAIT_OBJECT_0: 
                    __try { 
                        // TODO: Write to the database
                        printf("Thread %d writing to database...\n", 
                                GetCurrentThreadId());
                        dwCount++;
                    } 
    
                    __finally { 
                        // Release ownership of the mutex object
                        if (! ReleaseMutex(ghMutex)) 
                        { 
                            // Handle error.
                        } 
                    } 
                    break; 
    
                // The thread got ownership of an abandoned mutex
                // The database is in an indeterminate state
                case WAIT_ABANDONED: 
                    return FALSE; 
            }
        }
        return TRUE; 
    }
    

    分析如下:

    • 首先创建了存放两个线程句柄的aThread数组
    • 然后调用CreateThread函数创建了两个线程,这两个线程的作用都是进行写数据库操作(因为是以WriteToDatabase为内容创建的线程),并将两个线程的句柄存在aThread
    • WriteToDatabase函数体内,线程在执行写操作时( // TODO: Write to the database 所在的括号内的代码)都会先调用WaitForSingleObject函数,该函数的作用就是当前线程(比如aThread[0])需要请求对mutex的所有权,一旦请求到了,才允许进行数据库的写操作,其中INFINITE参数表示无限,用在这里的意思是,当前线程会一直请求对mutex的所有权,请求不到,一直等在WaitForSingleObject这行代码处,直到获取到所有权之后才往后执行。
    • 在main函数中(是主线程中的main函数)调用WaitForMultipleObjects来等待两个线程对象aThread[0],aThread[1]变得可用,才执行后面的关闭两线程对象的操作
    • 最终需要关闭mutex对象(CloseHandle(ghMutex);)

    因为Windows对于资源的分配是有限度的,不可能无限制的分配线程资源、句柄资源,所以程序使用完毕后需要将资源还给Windows操作系统。

    以上就是通过mutex对象来保证多个线程对同一个数据库进行写操作保护的具体案例。这个写操作和"同步(Synchronization)"一节中举例的大括号中的代码没有任何区别。

Event

案例前景:

主线程正在对一块共享内存进行写操作,其他线程需要对这块共享内容进行读操作,在这个主线程进行写过程结束之前,不应该有其他线程对这块共享内存进行 访问操作,除了上面说到的mutex对象外,也可以用Event对象。还是以微软提供的代码进行讲解:

#include <windows.h>
#include <stdio.h>

#define THREADCOUNT 4 

HANDLE ghWriteEvent; 
HANDLE ghThreads[THREADCOUNT];

DWORD WINAPI ThreadProc(LPVOID);

void CreateEventsAndThreads(void) 
{
    int i; 
    DWORD dwThreadID; 

    // Create a manual-reset event object. The write thread sets this
    // object to the signaled state when it finishes writing to a 
    // shared buffer. 

    ghWriteEvent = CreateEvent( 
        NULL,               // default security attributes
        TRUE,               // manual-reset event
        FALSE,              // initial state is nonsignaled
        TEXT("WriteEvent")  // object name
        ); 

    if (ghWriteEvent == NULL) 
    { 
        printf("CreateEvent failed (%d)\n", GetLastError());
        return;
    }

    // Create multiple threads to read from the buffer.

    for(i = 0; i < THREADCOUNT; i++) 
    {
        // TODO: More complex scenarios may require use of a parameter
        //   to the thread procedure, such as an event per thread to  
        //   be used for synchronization.
        ghThreads[i] = CreateThread(
            NULL,              // default security
            0,                 // default stack size
            ThreadProc,        // name of the thread function
            NULL,              // no thread parameters
            0,                 // default startup flags
            &dwThreadID); 

        if (ghThreads[i] == NULL) 
        {
            printf("CreateThread failed (%d)\n", GetLastError());
            return;
        }
    }
}

void WriteToBuffer(VOID) 
{
    // TODO: Write to the shared buffer.
    
    printf("Main thread writing to the shared buffer...\n");

    // Set ghWriteEvent to signaled

    if (! SetEvent(ghWriteEvent) ) 
    {
        printf("SetEvent failed (%d)\n", GetLastError());
        return;
    }
}

void CloseEvents()
{
    // Close all event handles (currently, only one global handle).
    
    CloseHandle(ghWriteEvent);
}

int main( void )
{
    DWORD dwWaitResult;

    // TODO: Create the shared buffer

    // Create events and THREADCOUNT threads to read from the buffer

    CreateEventsAndThreads();

    // At this point, the reader threads have started and are most
    // likely waiting for the global event to be signaled. However, 
    // it is safe to write to the buffer because the event is a 
    // manual-reset event.
    
    WriteToBuffer();

    printf("Main thread waiting for threads to exit...\n");

    // The handle for each thread is signaled when the thread is
    // terminated.
    dwWaitResult = WaitForMultipleObjects(
        THREADCOUNT,   // number of handles in array
        ghThreads,     // array of thread handles
        TRUE,          // wait until all are signaled
        INFINITE);

    switch (dwWaitResult) 
    {
        // All thread objects were signaled
        case WAIT_OBJECT_0: 
            printf("All threads ended, cleaning up for application exit...\n");
            break;

        // An error occurred
        default: 
            printf("WaitForMultipleObjects failed (%d)\n", GetLastError());
            return 1;
    } 
            
    // Close the events to clean up

    CloseEvents();

    return 0;
}

DWORD WINAPI ThreadProc(LPVOID lpParam) 
{
    // lpParam not used in this example.
    UNREFERENCED_PARAMETER(lpParam);

    DWORD dwWaitResult;

    printf("Thread %d waiting for write event...\n", GetCurrentThreadId());
    
    dwWaitResult = WaitForSingleObject( 
        ghWriteEvent, // event handle
        INFINITE);    // indefinite wait

    switch (dwWaitResult) 
    {
        // Event object was signaled
        case WAIT_OBJECT_0: 
            //
            // TODO: Read from the shared buffer
            //
            printf("Thread %d reading from buffer\n", 
                   GetCurrentThreadId());
            break; 

        // An error occurred
        default: 
            printf("Wait error (%d)\n", GetLastError()); 
            return 0; 
    }

    // Now that we are done reading the buffer, we could use another
    // event to signal that this thread is no longer reading. This
    // example simply uses the thread handle for synchronization (the
    // handle is signaled when the thread terminates.)

    printf("Thread %d exiting\n", GetCurrentThreadId());
    return 1;
}

分析如下(看主线程的main中代码,这个才是真正的程序逻辑):

  • 首先通过CreateEvent函数创建了一个多个线程可以共享的ghWriteEvent事件对象
  • 然后通过CreateThread创建了THREADCOUNT(4)个线程对象,并将线程的句柄保存在ghThreads[i]数组中
  • 然后主线程调用WriteToBuffer对共享内存进行写操作,写操作结束需要将事件对象ghWriteEvent设置为可用状态,其他线程想要访问共享内存,必须查询ghWriteEvent是否处于可用状态,只有在ghWriteEvent处于可用状态,我们创建的四个线程才被允许访问共享资源,这个过程查询的过程就体现在ThreadProc函数的WaitForSingleObject调用上。之后便是访问共享资源的操作,体现在WAIT_OBJECT_0case语句中
  • 主线程通过WaitForMultipleObjects等待四个线程都变成可用状态,在此之前,主线程处于阻塞状态。
  • 主线程等到之后,就可以释放线程资源和ghWriteEvent事件对象资源了,然后主线程结束,程序退出。

Semaphore

信号量对象。用来限制只有一定数量的线程来执行某一特定的任务。

还是以微软提供的代码进行讲解:

#include <windows.h>
#include <stdio.h>

#define MAX_SEM_COUNT 10
#define THREADCOUNT 12

HANDLE ghSemaphore;

DWORD WINAPI ThreadProc( LPVOID );

int main( void )
{
    HANDLE aThread[THREADCOUNT];
    DWORD ThreadID;
    int i;

    // Create a semaphore with initial and max counts of MAX_SEM_COUNT

    ghSemaphore = CreateSemaphore( 
        NULL,           // default security attributes
        MAX_SEM_COUNT,  // initial count
        MAX_SEM_COUNT,  // maximum count
        NULL);          // unnamed semaphore

    if (ghSemaphore == NULL) 
    {
        printf("CreateSemaphore error: %d\n", GetLastError());
        return 1;
    }

    // Create worker threads

    for( i=0; i < THREADCOUNT; i++ )
    {
        aThread[i] = CreateThread( 
                     NULL,       // default security attributes
                     0,          // default stack size
                     (LPTHREAD_START_ROUTINE) ThreadProc, 
                     NULL,       // no thread function arguments
                     0,          // default creation flags
                     &ThreadID); // receive thread identifier

        if( aThread[i] == NULL )
        {
            printf("CreateThread error: %d\n", GetLastError());
            return 1;
        }
    }

    // Wait for all threads to terminate

    WaitForMultipleObjects(THREADCOUNT, aThread, TRUE, INFINITE);

    // Close thread and semaphore handles

    for( i=0; i < THREADCOUNT; i++ )
        CloseHandle(aThread[i]);

    CloseHandle(ghSemaphore);

    return 0;
}

DWORD WINAPI ThreadProc( LPVOID lpParam )
{

    // lpParam not used in this example
    UNREFERENCED_PARAMETER(lpParam);

    DWORD dwWaitResult; 
    BOOL bContinue=TRUE;

    while(bContinue)
    {
        // Try to enter the semaphore gate.
		// 超时时间设置为0表示,当信号量对象处于不可用状态时,函数会立即返回
        dwWaitResult = WaitForSingleObject( 
            ghSemaphore,   // handle to semaphore
            0L);           // zero-second time-out interval

        switch (dwWaitResult) 
        { 
            // The semaphore object was signaled.
            case WAIT_OBJECT_0: 
                // TODO: Perform task
                printf("Thread %d: wait succeeded\n", GetCurrentThreadId());
                bContinue=FALSE;            

                // Simulate thread spending time on task
                Sleep(5);

                // Release the semaphore when task is finished

                if (!ReleaseSemaphore( 
                        ghSemaphore,  // handle to semaphore
                        1,            // increase count by one
                        NULL) )       // not interested in previous count
                {
                    printf("ReleaseSemaphore error: %d\n", GetLastError());
                }
                break; 

            // The semaphore was nonsignaled, so a time-out occurred.
            case WAIT_TIMEOUT: 
                printf("Thread %d: wait timed out\n", GetCurrentThreadId());
                break; 
        }
    }
    return TRUE;
}

分析如下:

  • 首先创建一个被多个线程(这里是13个,13=THREADCOUNT +1,1是主线程)共享的信号量对象,并且指定他的初始count计数和最大count计数都为MAX_SEM_COUNT(10)
  • 然后创建12个线程,将线程的句柄保存在aThread数组中
  • 然后主线程调用WaitForMultipleObjects来等待12个线程对象都变成可用状态。
  • 12个线程内部的执行逻辑(ThreadProc内部)如下
    • 调用WaitForSingleObject来等待ghSemaphore变为可用状态,可用时才能执行线程中真正的业务代码,体现在WAIT_OBJECT_0case语句中
  • 主线程等到12个线程都成为可用状态后,释放线程句柄、释放信号量对象,结束程序。

以上虽然创建了12个线程来执行特定的任务(在ThreadProcWAIT_OBJECT_0中),这个特定的任务当然也可以是对共享资源的访问,但是我程序只允许最多只能有10个线程来执行这个任务,所以可以用信号量的机制,并指定初始值10来做这种限制。

信号量的机制原理就是:指定一个最大值max,每一次一个线程获取到对于该信号量的拥有权(换种方式说:一个线程等到该信号量是可用状态时),信号量的计数值就会自动减1,如果计数值减到0之后,那么其他线程就等不到这个信号量变为有用状态,言外之意就是在这个信号量约束下的线程中的代码就得不到执行。当其中某个线程结束并且释放了信号量,那么该信号量的计数值就增加1,那么就可以有其他线程可以等到信号量并被调度执行。所以信号量机制的存在可以保证有多个线程执行某一特殊任务,但是对于多线程读写的行为,还是不宜用信号量,因为会发生数据的错误。但是多线程的读读行为,并且对于线程资源有数量上的约束行为,可以使用信号量。

特例:对于信号量的最计数值为1的信号量,可以实现多线程的读写行为,因为此时信号量和mutex、event在功能上没有任何区别。

Critical Section

临界区对象。

// Global variable
CRITICAL_SECTION CriticalSection; 

int main( void )
{
    ...

    // Initialize the critical section one time only.
    if (!InitializeCriticalSectionAndSpinCount(&CriticalSection, 
        0x00000400) ) 
        return;
    ...

    // Release resources used by the critical section object.
    DeleteCriticalSection(&CriticalSection);
}

DWORD WINAPI ThreadProc( LPVOID lpParameter )
{
    ...

    // Request ownership of the critical section.
    EnterCriticalSection(&CriticalSection); 

    // Access the shared resource.

    // Release ownership of the critical section.
    LeaveCriticalSection(&CriticalSection);

    ...
return 1;
}
  • 创建全局的临界区对象CriticalSection
  • 调用InitializeCriticalSectionAndSpinCount初始化临界区对象
  • 主线程main中的...可以表示对共享资源的访问,这里的写法比较懒,竟然连创建其他线程的代码也省去了,具体怎么创建可以参照章节"Mutex"、“Event”、“Semaphore”
  • ThreadProc定义了其他线程对共享资源的访问代码,在访问前需要调用EnterCriticalSection进入临界区,访问结束,调用LeaveCriticalSection离开临界区

Condition Variables

条件变量。

注意 在Windows Server 2003和Windows XP系统上,条件变量是不支持的。

以微软的代码来看结合条件变量和临界区对象实现的生产者消费者队列。

#include <windows.h>
#include <stdlib.h>
#include <stdio.h>

#define BUFFER_SIZE 10
#define PRODUCER_SLEEP_TIME_MS 500
#define CONSUMER_SLEEP_TIME_MS 2000

LONG Buffer[BUFFER_SIZE];
LONG LastItemProduced;
ULONG QueueSize;
ULONG QueueStartOffset;

ULONG TotalItemsProduced;
ULONG TotalItemsConsumed;

CONDITION_VARIABLE BufferNotEmpty;
CONDITION_VARIABLE BufferNotFull;
CRITICAL_SECTION   BufferLock;

BOOL StopRequested;

DWORD WINAPI ProducerThreadProc (PVOID p)
{
    ULONG ProducerId = (ULONG)(ULONG_PTR)p;

    while (true)
    {
        // Produce a new item.

        Sleep (rand() % PRODUCER_SLEEP_TIME_MS);

        ULONG Item = InterlockedIncrement (&LastItemProduced);

        EnterCriticalSection (&BufferLock);

        while (QueueSize == BUFFER_SIZE && StopRequested == FALSE)
        {
            // Buffer is full - sleep so consumers can get items.
            SleepConditionVariableCS (&BufferNotFull, &BufferLock, INFINITE);
        }

        if (StopRequested == TRUE)
        {
            LeaveCriticalSection (&BufferLock);
            break;
        }

        // Insert the item at the end of the queue and increment size.

        Buffer[(QueueStartOffset + QueueSize) % BUFFER_SIZE] = Item;
        QueueSize++;
        TotalItemsProduced++;

        printf ("Producer %u: item %2d, queue size %2u\r\n", ProducerId, Item, QueueSize);

        LeaveCriticalSection (&BufferLock);

        // If a consumer is waiting, wake it.

        WakeConditionVariable (&BufferNotEmpty);
    }

    printf ("Producer %u exiting\r\n", ProducerId);
    return 0;
}

DWORD WINAPI ConsumerThreadProc (PVOID p)
{
    ULONG ConsumerId = (ULONG)(ULONG_PTR)p;

    while (true)
    {
        EnterCriticalSection (&BufferLock);

        while (QueueSize == 0 && StopRequested == FALSE)
        {
            // Buffer is empty - sleep so producers can create items.
            SleepConditionVariableCS (&BufferNotEmpty, &BufferLock, INFINITE);
        }

        if (StopRequested == TRUE && QueueSize == 0)
        {
            LeaveCriticalSection (&BufferLock);
            break;
        }

        // Consume the first available item.

        LONG Item = Buffer[QueueStartOffset];

        QueueSize--;
        QueueStartOffset++;
        TotalItemsConsumed++;

        if (QueueStartOffset == BUFFER_SIZE)
        {
            QueueStartOffset = 0;
        }

        printf ("Consumer %u: item %2d, queue size %2u\r\n", 
            ConsumerId, Item, QueueSize);

        LeaveCriticalSection (&BufferLock);

        // If a producer is waiting, wake it.

        WakeConditionVariable (&BufferNotFull);

        // Simulate processing of the item.

        Sleep (rand() % CONSUMER_SLEEP_TIME_MS);
    }

    printf ("Consumer %u exiting\r\n", ConsumerId);
    return 0;
}

int main ( void )
{
    InitializeConditionVariable (&BufferNotEmpty);
    InitializeConditionVariable (&BufferNotFull);

    InitializeCriticalSection (&BufferLock);

    DWORD id;
    HANDLE hProducer1 = CreateThread (NULL, 0, ProducerThreadProc, (PVOID)1, 0, &id);
    HANDLE hConsumer1 = CreateThread (NULL, 0, ConsumerThreadProc, (PVOID)1, 0, &id);
    HANDLE hConsumer2 = CreateThread (NULL, 0, ConsumerThreadProc, (PVOID)2, 0, &id);

    puts ("Press enter to stop...");
    getchar();

    EnterCriticalSection (&BufferLock);
    StopRequested = TRUE;
    LeaveCriticalSection (&BufferLock);

    WakeAllConditionVariable (&BufferNotFull);
    WakeAllConditionVariable (&BufferNotEmpty);

    WaitForSingleObject (hProducer1, INFINITE);
    WaitForSingleObject (hConsumer1, INFINITE);
    WaitForSingleObject (hConsumer2, INFINITE);

    printf ("TotalItemsProduced: %u, TotalItemsConsumed: %u\r\n", 
        TotalItemsProduced, TotalItemsConsumed);
    return 0;
}
  • 首先定义了几个全局变量

    • 指定一个大小为BUFFER_SIZEBuffer

    • CONDITION_VARIABLE BufferNotEmpty; 被消费者使用

    • CONDITION_VARIABLE BufferNotFull; 被生产者使用

    • CRITICAL_SECTION BufferLock; 保护生产者-消费者队列

  • 先看生产者队列

    对于生产者来说,只要队列不满,就可以往里放数据,满了,就不允许再放数据,让自己进入等待状态,具体看执行体ProducerThreadProc

    • 进入临界区
    • 当队列满了的时候并且StopRequestedFALSE,将生产者线程睡眠在条件变量BufferNotFull
    • 如果StopRequestedTRUE离开临界区,此时临界区对象保护的是条件变量BufferNotFull
    • 当队列没有满的时候,往Buffer中放入一个数据
    • 放入数据结束离开临界区
    • 如果有一个消费者等在BufferNotEmpty上(Buffer中有数据,且有消费者可以消费,告诉你,你来消费吧),调用WakeConditionVariable (&BufferNotEmpty);唤醒正在等着的线程,让CPU调度其运行
  • 再看消费者队列

    对于消费者来说,只要队列不空,就可以从里取数据进行消费,空了,就不允许再取数据,让自己进入等待状态,具体看执行体ConsumerThreadProc

    • 进入临界区
    • 当队列为空且StopRequestedFALSE,将消费者线程睡眠在条件变量BufferNotEmpty
    • 如果StopRequestedTRUE离开临界区且队列为空,离开临界区
    • 取数据进行消费
    • 离开临界区
    • 如果有一个生产者等在BufferNotFull上(Buffer上至少有一个空槽可以让生产者生产数据,告诉生产者,你可以生产数据啦),唤醒它
  • 最后看主线程的main函数

    • 对条件变量和临界区变量的初始化
    • 创建一个生产者线程,两个消费者线程
    • 通过临界区变量设置标志StopRequested为TRUE
    • 唤醒所有的条件变量
    • 等待生产者线程变为可用状态(生产者线程执行结束)
    • 等待消费者线程1变为可用状态(消费者线程1执行结束)
    • 等待消费者线程2变为可用状态(消费者线程2执行结束)
    • 释放线程资源和其他资源(这里代码中好像没有体现…)
    • 主线程结束,程序结束。

备注: 以上所说的对象变成可用状态在Windows中有一个专用名词叫做signaled状态,二者是等价的,可用状态是我个人的理解,signaled状态是官方的说法。
博客中如何插入表情,参考这位博主的文章即可。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值