多线程锁详解之【互斥量】

更多的锁介绍可以先看看这篇文章:多线程锁详解之【序章】

正文:
互斥量,一个应用起来跟临界区十分相似的锁,但互斥量是可以命名的,可以跨进程使用,而临界区只能在单个进程内使用,效率方面互斥量往往也比临界区要低,这是什么原因导致的呢?让我们先来学习一遍互斥锁的源码,再讨论上面提出的问题。

源码:
先来看看互斥锁是如何创建的:


typedef struct _KMUTANT {
  DISPATCHER_HEADER Header;
  LIST_ENTRY MutantListEntry; //是个链表节点,用来作为线程互斥门链表的节点
  struct _KTHREAD *RESTRICTED_POINTER OwnerThread; //正在拥有互斥门的线程
  BOOLEAN Abandoned; //发生一次抛弃事件
  UCHAR ApcDisable; //是否禁用内核APC
} KMUTANT, *PKMUTANT, *RESTRICTED_POINTER PRKMUTANT, KMUTEX, 
*PKMUTEX, *RESTRICTED_POINTER PRKMUTEX;

/* 
//提供一个 wdm.h (微软)的定义给大家看看,但我们以 ReactOS 的定义作为讲解
typedef struct _KMUTANT {
  DISPATCHER_HEADER Header;
  LIST_ENTRY        MutantListEntry;
  struct _KTHREAD   *OwnerThread;
  union {
    UCHAR MutantFlags;
    struct {
      UCHAR Abandoned : 1;
      UCHAR Spare1 : 7;
    } DUMMYSTRUCTNAME;
  } DUMMYUNIONNAME;
  UCHAR             ApcDisable;
} KMUTANT, *PKMUTANT, *PRKMUTANT, KMUTEX, *PKMUTEX, *PRKMUTEX;
*/

NTSTATUS NTAPI NtCreateMutant(OUT PHANDLE MutantHandle,
               IN ACCESS_MASK DesiredAccess,
               IN POBJECT_ATTRIBUTES ObjectAttributes  OPTIONAL,
               IN BOOLEAN InitialOwner)
{
    KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
    HANDLE hMutant;
    PKMUTANT Mutant;
    NTSTATUS Status;
    PAGED_CODE();
    DPRINT("NtCreateMutant(0x%p, 0x%x, 0x%p)\n",
            MutantHandle, DesiredAccess, ObjectAttributes);

    /* Check if we were called from user-mode */
    if (PreviousMode != KernelMode)
    {
        /* Enter SEH Block */
        _SEH2_TRY
        {
            /* Check handle pointer */
            ProbeForWriteHandle(MutantHandle);
        }
        _SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER)
        {
            /* Return the exception code */
            _SEH2_YIELD(return _SEH2_GetExceptionCode());
        }
        _SEH2_END;
    }

    /* Create the Mutant Object*/
    Status = ObCreateObject(PreviousMode,
                            ExMutantObjectType,
                            ObjectAttributes,
                            PreviousMode,
                            NULL,
                            sizeof(KMUTANT),
                            0,
                            0,
                            (PVOID*)&Mutant);

    /* Check for success */
    if(NT_SUCCESS(Status))
    {
        /* Initalize the Kernel Mutant */
        DPRINT("Initializing the Mutant\n");
        KeInitializeMutant(Mutant, InitialOwner);

        /* Insert the Object */
        Status = ObInsertObject((PVOID)Mutant,
                                NULL,
                                DesiredAccess,
                                0,
                                NULL,
                                &hMutant);

        /* Check for success */
        if (NT_SUCCESS(Status))
        {
            /* Enter SEH for return */
            _SEH2_TRY
            {
                /* Return the handle to the caller */
                *MutantHandle = hMutant;
            }
            _SEH2_EXCEPT(ExSystemExceptionFilter())
            {
                /* Get the exception code */
                Status = _SEH2_GetExceptionCode();
            }
            _SEH2_END;
        }
    }

    /* Return Status */
    return Status;
}

如果你已经看过了事件对象的创建过程,就会发现互斥锁创建的过程是如此的熟悉。这里我们不过多赘述这个函数的流程是做了写什么了,都是一些三板斧动作。主要是讲述一些与其他锁对象不同的地方。

首先是函数名,不知道你发现没,其他的内核锁,都是在 CreateXXX 函数加上 Nt 两个字母,而互斥量的Nt 层函数不叫 NtCreateMutex 而叫 NtCreateMutant ,为什么这样叫?说实话我也不知道,毕竟只是一个命名而已。一般 CreateMutex 函数称之为创建互斥量, NtCreateMutant 函数称之为创建互斥门,当然它们也可以统称为创建互斥锁(哈哈,有点嚼舌根)。

而 NtCreateMutant 构建出来的对象,也是以 DISPATCHER_HEADER 结构体作为第一个成员,所以说 DISPATCHER_HEADER 这个结构体是构成锁的核心要素。而为了更好地了解 KMUTANT 这个结构体,我们先看看 KeInitializeMutant 如何对它初始化:

VOID
NTAPI
KeInitializeMutant(IN PKMUTANT Mutant,
                   IN BOOLEAN InitialOwner)
{
    PKTHREAD CurrentThread;
    KIRQL OldIrql;

    /* Check if we have an initial owner */
    if (InitialOwner)
    {
        /* We also need to associate a thread */
        CurrentThread = KeGetCurrentThread();
        Mutant->OwnerThread = CurrentThread;

        /* We're about to touch the Thread, so lock the Dispatcher */
        OldIrql = KiAcquireDispatcherLock();

        /* And insert it into its list */
        InsertTailList(&CurrentThread->MutantListHead,
                       &Mutant->MutantListEntry);

        /* Release Dispatcher Lock */
        KiReleaseDispatcherLock(OldIrql);
    }
    else
    {
        /* In this case, we don't have an owner yet */
        Mutant->OwnerThread = NULL;
    }

    /* Now we set up the Dispatcher Header */
    Mutant->Header.Type = MutantObject;
    Mutant->Header.Size = sizeof(KMUTANT) / sizeof(ULONG);
    Mutant->Header.DpcActive = FALSE;
    Mutant->Header.SignalState = InitialOwner ? 0 : 1;
    InitializeListHead(&(Mutant->Header.WaitListHead));

    /* Initialize the default data */
    Mutant->Abandoned = FALSE; //是否发生了抛弃事件
    Mutant->ApcDisable = 0; //禁用APC
}

呀,比事件对象信号灯这些锁的初始化要复杂点。主要是多了个 InitialOwner 参数的判断(本质是 CreateMutex 的第二个参数),当 InitialOwner 为 FALSE 的时候,明显是用来第一次初始化结构体的,而当 InitialOwner 为 TRUE 时,KeInitializeMutant 函数除了初始化互斥门对象,还能把当前线程标记为拥有了互斥锁的使用权。而当线程拥有使用权后,会把互斥门对象保存到 CurrentThread->MutantListHead 队列当中,这有什么作用呢?
大家还记得互斥量的一个特性吗?就是当拥有互斥锁权限的线程,在结束时没有释放锁权限的话,系统会帮其释放锁权限。这就是为什么当线程拥有了锁时,要把互斥门存放到 CurrentThread->MutantListHead 的原因。
那为什么系统要主动帮线程释放互斥锁权限呢?因为互斥锁常用于跨进程操作,有时候进程崩溃是不可控的,如果系统不主动帮忙释放互斥锁的话,将会导致其他访问同一个互斥锁的进程无辜卡死。当系统主动帮我们释放互斥锁后,下一次的 WaitForSingleObject 函数的调用将会返回 WAIT_ABANDONED 错误。
而 KMUTANT 结构当中的 OwnerThread 成员也比较简单,它跟临界区的 OwnerThread 成员变量一样,都是用来标记哪条线程拥有了锁权限。但临界区还有一个 RecursionCount 的成员变量,用来作为线程重入计数的。互斥门同样支持线程重入(也就是一个线程可以多次嵌套调用 WaitForSingleObject 函数),那互斥门的 RecursionCount 跑哪里去了呢?
其实互斥门的重入计数,是利用了 SignalState 变量作计算。哈哈,看来 SignalState 变量的作用真不简单,而至于 SignalState 变量是怎么运算的呢?我们先来看看 NtReleaseMutant 和 KeReleaseMutant 函数吧。

NTSTATUS NTAPI NtReleaseMutant(IN HANDLE MutantHandle,
                IN PLONG PreviousCount OPTIONAL)
{
    PKMUTANT Mutant;
    KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
    NTSTATUS Status;
    PAGED_CODE();
    DPRINT("NtReleaseMutant(MutantHandle 0x%p PreviousCount 0x%p)\n",
            MutantHandle,
            PreviousCount);

     /* Check if we were called from user-mode */
    if ((PreviousCount) && (PreviousMode != KernelMode))
    {
        /* Entry SEH Block */
        _SEH2_TRY
        {
            /* Make sure the state pointer is valid */
            ProbeForWriteLong(PreviousCount);
        }
        _SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER)
        {
            /* Return the exception code */
            _SEH2_YIELD(return _SEH2_GetExceptionCode());
        }
        _SEH2_END;
    }

    /* Open the Object */
    Status = ObReferenceObjectByHandle(MutantHandle,
                                       MUTANT_QUERY_STATE,
                                       ExMutantObjectType,
                                       PreviousMode,
                                       (PVOID*)&Mutant,
                                       NULL);

    /* Check for Success and release if such */
    if (NT_SUCCESS(Status))
    {
        /*
         * Release the mutant. doing so might raise an exception which we're
         * required to catch!
         */
        _SEH2_TRY
        {
            /* Release the mutant */
            LONG Prev = KeReleaseMutant(Mutant,
                                        MUTANT_INCREMENT,
                                        FALSE,
                                        FALSE);

            /* Return the previous count if requested */
            if (PreviousCount) *PreviousCount = Prev;
        }
        _SEH2_EXCEPT(ExSystemExceptionFilter())
        {
            /* Get the exception code */
            Status = _SEH2_GetExceptionCode();
        }
        _SEH2_END;

        /* Dereference it */
        ObDereferenceObject(Mutant);
    }

    /* Return Status */
    return Status;
}


LONG NTAPI KeReleaseMutant(IN PKMUTANT Mutant,
                IN KPRIORITY Increment, //MUTANT_INCREMENT : 1
                IN BOOLEAN Abandon, //FALSE
                IN BOOLEAN Wait //FALSE
                )
{
    KIRQL OldIrql;
    LONG PreviousState;
    PKTHREAD CurrentThread = KeGetCurrentThread();
    BOOLEAN EnableApc = FALSE;
    ASSERT_MUTANT(Mutant);
    ASSERT_IRQL_LESS_OR_EQUAL(DISPATCH_LEVEL);

    /* Lock the Dispatcher Database */
    OldIrql = KiAcquireDispatcherLock();//加锁

    /* Save the Previous State */
    PreviousState = Mutant->Header.SignalState; //保存下来

    /* Check if it is to be abandonned */
    if (Abandon == FALSE)
    {
        /* Make sure that the Owner Thread is the current Thread */
        if (Mutant->OwnerThread != CurrentThread)
        {
        	//如果当前线程不是锁的持有者,则抛出异常
            /* Release the lock */
            KiReleaseDispatcherLock(OldIrql);释放锁

            /* Raise an exception */
            ExRaiseStatus(Mutant->Abandoned ? STATUS_ABANDONED :
                                              STATUS_MUTANT_NOT_OWNED);
        }

        /* If the thread owns it, then increase the signal state */
        Mutant->Header.SignalState++; //信号量加 1 
    }
    else /*if (Abandon == TRUE)*/
    {
        /* It's going to be abandonned */
        //Abandon 为 TRUE,是线程在退出前,没有完全释放锁的缘故,这个时候
        //系统会帮我们调用一次 KeReleaseMutant ,把锁释放掉
        //既然某个线程已经彻底弃用这个锁,那么不管它曾经重入多少次
        //到了这里 SignalState 都强行置 1 ,SignalState 为 1 就代表有信号了
        //SignalState 有信号就能够唤醒另外一条休眠的线程(如果阻塞队列不为空)
        Mutant->Header.SignalState = 1;
        Mutant->Abandoned = TRUE; //没有被正确释放,设置标记
    }

    /* Check if the signal state is only single */
    if (Mutant->Header.SignalState == 1)
    {
        /* Check if it's below 0 now */
        if (PreviousState <= 0) 
        {
        	//如果当前 SignalState 为 1,且之前为 0 ,则表示已经当前线程已经完全释放锁
        	//所以线程结构体也不需要再记录互斥门,把互斥门从线程对象中移除
            /* Remove the mutant from the list */
            RemoveEntryList(&Mutant->MutantListEntry);

            /* Save if we need to re-enable APCs */
            EnableApc = Mutant->ApcDisable;
        }
        //else :


        /* Remove the Owning Thread and wake it */
        Mutant->OwnerThread = NULL; //SignalState 为 TRUE 表示没线程占用了

        /* Check if the Wait List isn't empty */
        //如果阻塞队列不为空,则表示有其他线程等待这个互斥锁
        if (!IsListEmpty(&Mutant->Header.WaitListHead))
        {
            /* Wake the Mutant */
            KiWaitTest(&Mutant->Header, Increment);//唤醒一条正在等待的线程
        }
    }

    /* Check if the caller wants to wait after this release */
    if (Wait == FALSE)
    {
        /* Release the Lock */
        KiReleaseDispatcherLock(OldIrql);
    }
    else
    {
        /* Set a wait */
        CurrentThread->WaitNext = TRUE;
        CurrentThread->WaitIrql = OldIrql;
    }

    /* Check if we need to re-enable APCs */
    if (EnableApc) KeLeaveCriticalRegion();

    /* Return the previous state */
    return PreviousState;//返回之前的状态
}

由于 NtReleaseMutant 都是一些三板斧套路,我们跳过 NtReleaseMutant 函数直接分析 KeReleaseMutant 函数。
可以看到 KeReleaseMutant 对 SignalState 的处理是递增,那么就意味着 WaitForSingleObject 函数里面是递减操作了。我们动动手指计算一下,
初始化的互斥门(线程没申请锁权限)的 SignalState 值为 1
然后调用 WaitForSingleObject 拿到锁权限时,SignalState 递减等于 0
那线程重入时,再一次调用 WaitForSingleObject ,这个函数为什么没发生阻塞呢?我摘取一小段 KeWaitForSingleObject 函数的代码给大家分析一下:

NTSTATUS
NTAPI
KeWaitForSingleObject(IN PVOID Object,
	IN KWAIT_REASON WaitReason, //线程上次被切出原因
	IN KPROCESSOR_MODE WaitMode,
	IN BOOLEAN Alertable,
	IN PLARGE_INTEGER Timeout OPTIONAL)
{
	/* Check if it's a mutant */
	//先检测所等待的对象是否有信号了,不过互斥对象需要特殊判断。
	if (CurrentObject->Header.Type == MutantObject)//包含Mutant结构的信号处理
	{
		/* Check its signal state or if we own it */
		if ((CurrentObject->Header.SignalState > 0) ||
			(Thread == CurrentObject->OwnerThread))
		{
			/* Just unwait this guy and exit */
			if (CurrentObject->Header.SignalState != (LONG)MINLONG)
			{
				/* It has a normal signal state. Unwait and return */
				//KiSatisfyMutantWait 负责把目标对象已经收到的信号消耗掉,它主要用于互斥量消耗,
				//互斥量收到信号后 SignalState 会加 1, 那么所谓消耗掉,就是把 SignalState 减 1
				//因为互斥量是线程可重入,所以它会对 SignalState 进行加减计算,重入时是加 1
				KiSatisfyMutantWait(CurrentObject, Thread);//包含Mutant结构的信号消耗
				WaitStatus = (NTSTATUS)Thread->WaitStatus;//唤醒原因
				goto DontWait;//退出循环,函数准备返回
			}
			else
			{
				/* Raise an exception */
				//这里就是 if (CurrentObject->Header.SignalState == (LONG)MINLONG) 条件的处理代码
				//SignalState 到达 MINLONG 表示正整数溢出,变为负数,意味着这个时候锁计数已经太多了。
				KiReleaseDispatcherLock(Thread->WaitIrql); //先执行 执行所有Pending中的内核APC

		  //重入次数已经太多了,这里只能抛出一个异常
				ExRaiseStatus(STATUS_MUTANT_LIMIT_EXCEEDED);
			}
		}
	}
}

哦,原来当 SignalState 大于 0,或者 OwnerThread 为当前线程,WaitForSingleObject 函可以被唤醒,而被唤醒时,还会执行 KiSatisfyMutantWait 这个函数,额,准确来说,它是一个宏,我们来看看定义:

//
// Satisfies the wait of a mutant dispatcher object
//
#define KiSatisfyMutantWait(Object, Thread)                                 \
{                                                                           \
    /* Decrease the Signal State */                                         \
    (Object)->Header.SignalState--;                                         \
                                                                            \
    /* Check if it's now non-signaled */                                    \
    if (!(Object)->Header.SignalState)                                      \
    {                                                                       \
        /* Set the Owner Thread */                                          \
        (Object)->OwnerThread = Thread;                                     \
                                                                            \
        /* Disable APCs if needed */                                        \
        Thread->KernelApcDisable = Thread->KernelApcDisable -               \
                                   (Object)->ApcDisable;                    \
                                                                            \
        /* Check if it's abandoned */                                       \
        if ((Object)->Abandoned)                                            \
        {                                                                   \
            /* Unabandon it */                                              \
            (Object)->Abandoned = FALSE;                                    \
                                                                            \
            /* Return Status */                                             \
            Thread->WaitStatus = STATUS_ABANDONED;                          \
        }                                                                   \
                                                                            \
        /* Insert it into the Mutant List */                                \
        InsertHeadList(Thread->MutantListHead.Blink,                        \
                       &(Object)->MutantListEntry);                         \
    }                                                                       \
}

原来 WaitForSingleObject 对互斥门的递减,就是在 KiSatisfyMutantWait 这个宏里面完成的,而当 SignalState 由 1 变 0 时,意味着它被一个新的线程占用,所以 OwnerThread 被设置为新线程,而互斥门也被加入到线程的 MutantListHead 队列。之所以需要加入 MutantListHead 队列,之前也说过了,是为了让线程结束时,系统能够知道哪些互斥门还没有被正确释放,以便系统帮我们调用 KeReleaseMutant,而系统调用 KeReleaseMutant 函数时, Abandon 参数为 TRUE,这个时候互斥门内部的 Abandoned 也会被设置为TRUE,表示在此之前,发生了至少一次不愉快的事情(一个或多个线程没有正确释放互斥锁)。而当我们察觉到发生不愉快的事情后,会把内核的错误码设置为 STATUS_ABANDONED (转化到应用层为 WAIT_ABANDONED ),再把这件不愉快的事情抹去(把互斥门的 Abandoned 重置为 FALSE)。

疑问解答:
对于互斥量的实现源码,就介绍到这里了。接下来我们来解释一下文章开始时提出的疑问:

a1. 为什么互斥量比临界区效率要低
其实说互斥量的效率完全比临界区要低这个说法肯定是错误的,无论是临界区还是互斥量,他们陷入等待的方式都是调用 WaitForSingleObject 函数,虽然在 WaitForSingleObject 内部处理逻辑稍有差异,但肯定是难以判断出性能差距的。互斥量在锁碰撞时(至少两个线程在抢锁),是直接陷入内核等待,而临界区是用户层自旋4000次抢锁,用户层抢锁失败再陷入内核等待。
很明显,当你发现同样的代码,用临界区加锁比互斥量要快时,证明了临界区处于自旋状态时就把锁使用权拿到手了。如果临界区在自旋状态没能够把锁拿到手呢?那就意味着,他将跟互斥量一样陷入内核等待。由于临界区的等待过程比互斥量还多了自旋这个操作,也就是说,如果临界区不能在自旋状态把锁抢到手,那么它的效率必将比互斥量还要低。
如果是锁碰撞十分频繁且加锁粒度较大时(指代码很多或时间开销很大),可以考虑使用互斥量,说不定效率会更高。

a2. 为什么互斥量能跨进程而临界区不能跨进程
如果你问出这个问题,就要理解一下用户空间和内核空间的概念了。简单来说,用户空间是进程独占的,内核空间是所有进程共享的。也就是说,当不同的进程访问互斥量句柄,进入内核后它们最终访问的都是同一块内存地址,而不同进程的临界区,很明显他们地址不同,无法为不同进程提供同一个对象,也就无法实现跨进程加锁的动作。
那如果我把 CRITICAL_SECTION 结构放到内核层,并且以句柄的形式返回应用层呢? 答案是可以的,这个时候你的临界区将支持跨进程访问。但这样做有意义吗?
这个我没测试过,但看上去意义不大,也不能说这样的临界区性能是否比互斥量要好。因为内核临界区需要进入内核,查找对象,释放对象,退出内核等操作。这些动作的时间消耗,将会加大另外一个线程,在临界区自旋期间不能抢锁成功的概率,也就是这种场景下自旋锁可能会变成一个累赘,让内核临界区比内核互斥门更慢。嗯,理论上是这样的,实际上大家需要测试一下才知道实际效果。

a3. 互斥量如何解决死锁
在临界区的文章我们提及到,临界区解决死锁,可以自行添加一个链表,然后通过链路检查来判断死锁。死锁的判断条件有了,也是通用的。那么问题是当判断出产生死锁以后,如何解决死锁呢?
因为临界区中 unlock 函数并没有判断调用线程是否就是拥有锁的线程,所以在哪个线程解锁都没关系,然而互斥锁的 unlock 大家也看到了,它是会判断当前调用锁的线程是否就是锁的拥有者,这就意味着不能在其他线程调用互斥锁的 unlock 了。联想拥有互斥锁的线程在结束时没有释放锁权限的话,系统会帮其释放锁权限的特性。我们可以得出两个解决方案:
一是在抢锁线程判断出自己将要发生死锁,放弃自己已经抢到手的锁(死锁产生的条件,主要都是因为两个以上的锁没有保证调用顺序,一般都是在第二个或往后的锁才能判断产生了死锁),当然了,连带放弃的,还有本次线程需要执行的任务。
二是死锁产生后,在其他线程判断出来,将产生死锁的一条或多条线程终结掉,让系统帮我们释放互斥锁。

a4. 线程真的可以强行终结吗
在一些编程规范中会明确提到:强行终结线程,会引起内存泄露或数据损坏的问题,所以不允许强行终结线程;如果想结束一条线程,请使用 return 关键字让线程主动结束。
这个规范的另外一个意思,就是说不允许强行终结线程是因为害怕出现不可控的问题。反过来说,只要我们能够保证线程可控,线程是可以强行结束的。
且看以下代码:

int thread_A(void*)
{
	int a = 0;
	a++;
	return 0;
}

int thread_B(void*)
{
	int a = 0;
	void* p = malloc(100);
	a++;
	free(p);
	return 0;
}

请问 thread_A 线程强行结束会引起不可控后果吗,答案是不会,它跟正常 return 没有任何差别。强行结束 thread_B 呢?
强行结束 thread_B 可能会引起两个问题,一个是内存泄露,另外一个是如果 thread_B 正处于 malloc 或 free 内部被结束掉,会引起其他线程将无法再调用这两个函数,因为 malloc 和 free 内部是有锁(不是互斥锁)的,如果 thread_B 被终结时正处于 malloc / free 的加锁状态,那么其他调用这两个函数的线程将会死锁。
但在我们设计的场景中,上面第二个问题真的会发生吗?答案是不会的,因为当我们检测到线程死锁,那么这些线程百分百处于 lock 函数中,不会存在于其他地方!而且我们是有能力知道线程执行到哪一步的,所以我们主要关注内存泄露问题就可以了。
关于内存泄露的处理方案,比较简单的一个方法是,提供一个 struct _ttev (线程任务环境)的结构体,将需要用到的资源都放到里面,强行终结线程后,再将对应线程的 struct _ttev 对象回收即可。(参考进程终结为什么不会引起内存泄露的原因)
其实强行终结线程,还是十分危险的操作,对开发者的要求也非常高,大家在设计强行终结线程,一定要多加思考和检测!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值