linux内核部件分析(十一)——waitqueue与线程的阻塞


 

       当你必须一个复杂的系统,协调系统的方方面面,灵活地支持各种机制和策略,即使很简单的问题也会变得很复杂。linux绝对就是这样一个复杂的系统。所以我们要理解它,尽量从原理的角度去理解事务的处理流程,尽量避免各种细枝末节的干扰,尽量规避那些足以压垮自己的庞然大物。(尽管细致末节和庞然大物很可能就是linux闪光的地方,但我们还是小心为上。)

原理

    现在我们来考虑linux中线程的阻塞。它的原理很简单。我们有一个要阻塞的线程A和要唤醒它的线程B(当然也可以是中断处理例程ISR),有一个两者共知的等待队列Q(也许这个等待队列属于一个信号量什么的)。首先是线程A阻塞,要加入等待队列Q,需要先申请一个队列节点N,节点N中包含指向线程A的线程控制块(TCB)的指针,然后A就可以将自己的线程状态设为阻塞,并调用schedule()将自己踢出CPU的就绪队列。过了一定时间,线程B想要唤醒等待队列Q中的线程,它只需要获得线程A的TCB指针,将线程A状态设为就绪即可。等线程A恢复运行,将节点N退出等待队列Q,完成整个从阻塞到恢复的流程。

    原理讲起来总是没意思的,下面我们还是看代码吧。我们规避复杂的任务状态转换和调度的内容,即使对等待队列的分析,也是按照从基础到扩展的顺序。代码出现在三个地方:include/linux/wait.h , kernel/wait.c, kernel/sched.c。不用说wait.h是头文件,wait.c是实现的地方,而sched.c则体现了waitqueue的一种应用(实现completion)。为了更好地分析completion,我们还需要include/linux/completion.h。


waitqueue实现

我们仍然先看数据结构。

  1. <pre name="code" class="cpp"><pre name="code" class="cpp">struct __wait_queue_head {  
  2.     spinlock_t lock;  
  3.     struct list_head task_list;  
  4. };  
  5. typedef struct __wait_queue_head wait_queue_head_t;  
  6.   
  7. typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);  
  8.   
  9. int default_wake_function(wait_queue_t *wait, unsigned mode, int flags, void *key);  
  10.   
  11. struct __wait_queue {  
  12.     unsigned int flags;  
  13. #define WQ_FLAG_EXCLUSIVE   0x01  
  14.     void *private;  
  15.     wait_queue_func_t func;  
  16.     struct list_head task_list;  
  17. };  
  18.   
  19. typedef struct __wait_queue wait_queue_t;</pre><p></p>  
  20. <pre></pre>  
  21. <pre></pre>  
  22. 其中,wait_queue_head_t 就是等待队列头,wait_queue_t 就是队列节点。  
  23. <p></p>  
  24. <p>wait_queue_head_t 包括一个自旋锁lock,还有一个双向循环队列task_list,这在我们的预料之内。</p>  
  25. <p>wait_queue_t 则包括较多,我们先来剧透一下。</p>  
  26. <p>    flags变量只可能是0或者WQ_FLAG_EXCLUSIVE。flags标志只影响等待队列唤醒线程时的操作,置为WQ_FLAG_EXCLUSIVE则每次只允许唤醒一个线程,为0则无限制。</p>  
  27. <p>    private指针,其实就是指向TCB的指针。</p>  
  28. <p>    func是一个函数指针,指向用于唤醒队列中线程的函数。虽然提供了默认的唤醒函数default_wake_function,但也允许灵活的设置队列的唤醒函数。</p>  
  29. <p>    task_list是一个双向循环链表节点,用于链入等待队列的链表。</p>  
  30. <p><br>  
  31. </p>  
  32. <p>依照旧例,waitqueue在数据结构之后为我们提供了丰富的初始化函数。因为太多了,我们只好分段列出。</p>  
  33. <p></p><pre name="code" class="cpp">#define __WAITQUEUE_INITIALIZER(name, tsk) {                \  
  34.     .private    = tsk,                      \  
  35.     .func       = default_wake_function,            \  
  36.     .task_list  = { NULL, NULL } }  
  37.   
  38. #define DECLARE_WAITQUEUE(name, tsk)                    \  
  39.     wait_queue_t name = __WAITQUEUE_INITIALIZER(name, tsk)  
  40.   
  41. #define __WAIT_QUEUE_HEAD_INITIALIZER(name) {               \  
  42.     .lock       = __SPIN_LOCK_UNLOCKED(name.lock),      \  
  43.     .task_list  = { &(name).task_list, &(name).task_list } }  
  44.   
  45. #define DECLARE_WAIT_QUEUE_HEAD(name) \  
  46.     wait_queue_head_t name = __WAIT_QUEUE_HEAD_INITIALIZER(name)  
  47.   
  48. </pre>这是用宏定义,在声明变量时进行的初始化。<p></p>  
  49. <p></p><pre name="code" class="cpp">void __init_waitqueue_head(wait_queue_head_t *q, struct lock_class_key *key)  
  50. {  
  51.     spin_lock_init(&q->lock);  
  52.     lockdep_set_class(&q->lock, key);  
  53.     INIT_LIST_HEAD(&q->task_list);  
  54. }  
  55.   
  56. #define init_waitqueue_head(q)              \  
  57.     do {                        \  
  58.         static struct lock_class_key __key; \  
  59.                             \  
  60.         __init_waitqueue_head((q), &__key); \  
  61.     } while (0)  
  62.   
  63. #ifdef CONFIG_LOCKDEP  
  64. # define __WAIT_QUEUE_HEAD_INIT_ONSTACK(name) \  
  65.     ({ init_waitqueue_head(&name); name; })  
  66. # define DECLARE_WAIT_QUEUE_HEAD_ONSTACK(name) \  
  67.     wait_queue_head_t name = __WAIT_QUEUE_HEAD_INIT_ONSTACK(name)  
  68. #else  
  69. # define DECLARE_WAIT_QUEUE_HEAD_ONSTACK(name) DECLARE_WAIT_QUEUE_HEAD(name)  
  70. #endif</pre>这一段代码其实不要也可以,但因为是简单的细节,所以我们也覆盖到了。<p></p>  
  71. <p>init_wait_queue_head()对等待队列头进行初始化。</p>  
  72. <p>另外定义了宏DECLARE_WAIT_QUEUE_HEAD_ONSTACK。根据配置是否使用CONFIG_LOCKDEP,决定其实现。</p>  
  73. <p>spinlock很复杂,配置了CONFIG_LOCKDEP就会定义一个局部静态变量__key对spinlock使用的正确性进行检查。检查的过程很复杂,但既然是检查,就是可以</p>  
  74. <p>砍掉的。因为使用了局部静态变量,所以只能检查定义在栈上的变量,所以是DECLARE_WAIT_QUEUE_HEAD_ONSTACK。很多使用spinlock的地方都可以看到</p>  
  75. <p>这种检查。</p>  
  76. <p><br>  
  77. </p>  
  78. <p></p><pre name="code" class="cpp">static inline void init_waitqueue_entry(wait_queue_t *q, struct task_struct *p)  
  79. {  
  80.     q->flags = 0;  
  81.     q->private = p;  
  82.     q->func = default_wake_function;  
  83. }  
  84.   
  85. static inline void init_waitqueue_func_entry(wait_queue_t *q,  
  86.                     wait_queue_func_t func)  
  87. {  
  88.     q->flags = 0;  
  89.     q->private = NULL;  
  90.     q->func = func;  
  91. }  
  92.   
  93. static inline int waitqueue_active(wait_queue_head_t *q)  
  94. {  
  95.     return !list_empty(&q->task_list);  
  96. }</pre>init_waitqueue_entry()和init_waitqueue_func_entry()是用于初始化waitqueue的函数。<p></p>  
  97. <p>waitqueue_active()查看队列中是否有等待线程。</p>  
  98. <p><br>  
  99. </p>  
  100. <p></p><pre name="code" class="cpp">static inline void __add_wait_queue(wait_queue_head_t *head, wait_queue_t *new)  
  101. {  
  102.     list_add(&new->task_list, &head->task_list);  
  103. }  
  104.   
  105. /* 
  106.  * Used for wake-one threads: 
  107.  */  
  108. static inline void __add_wait_queue_tail(wait_queue_head_t *head,  
  109.                         wait_queue_t *new)  
  110. {  
  111.     list_add_tail(&new->task_list, &head->task_list);  
  112. }  
  113.   
  114. static inline void __remove_wait_queue(wait_queue_head_t *head,  
  115.                             wait_queue_t *old)  
  116. {  
  117.     list_del(&old->task_list);  
  118. }</pre>__add_wait_queue()将节点加入等待队列头部。<p></p>  
  119. <p>__add_wait_queue_tail()将节点加入等待队列尾部。</p>  
  120. <p>__remove_wait_queue()将节点从等待队列中删除。</p>  
  121. <p>这三个都是简单地用链表操作实现。</p>  
  122. <p><br>  
  123. </p>  
  124. <p></p><pre name="code" class="cpp">void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)  
  125. {  
  126.     unsigned long flags;  
  127.   
  128.     wait->flags &= ~WQ_FLAG_EXCLUSIVE;  
  129.     spin_lock_irqsave(&q->lock, flags);  
  130.     __add_wait_queue(q, wait);  
  131.     spin_unlock_irqrestore(&q->lock, flags);  
  132. }  
  133.   
  134. void add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait)  
  135. {  
  136.     unsigned long flags;  
  137.   
  138.     wait->flags |= WQ_FLAG_EXCLUSIVE;  
  139.     spin_lock_irqsave(&q->lock, flags);  
  140.     __add_wait_queue_tail(q, wait);  
  141.     spin_unlock_irqrestore(&q->lock, flags);  
  142. }  
  143.   
  144. void remove_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)  
  145. {  
  146.     unsigned long flags;  
  147.   
  148.     spin_lock_irqsave(&q->lock, flags);  
  149.     __remove_wait_queue(q, wait);  
  150.     spin_unlock_irqrestore(&q->lock, flags);  
  151. }</pre><p></p>  
  152. <p style="margin-top:4px; margin-right:0px; margin-bottom:4px; margin-left:0px; padding-top:2px; padding-right:0px; padding-bottom:2px; padding-left:0px">  
  153. add_wait_queue()将节点加入等待队列头部。</p>  
  154. <p style="margin-top:4px; margin-right:0px; margin-bottom:4px; margin-left:0px; padding-top:2px; padding-right:0px; padding-bottom:2px; padding-left:0px">  
  155. add_wait_queue_exclusive()将节点加入等待队列尾部。</p>  
  156. <p style="margin-top:4px; margin-right:0px; margin-bottom:4px; margin-left:0px; padding-top:2px; padding-right:0px; padding-bottom:2px; padding-left:0px">  
  157. remove_wait_queue()将节点从等待队列中删除。</p>  
  158. 这里三个函数和前面三个函数最大的区别就是这里加了禁止中断的自旋锁。从此也可以看出linux代码的一个特色。以双下划线前缀的函数往往是供内部调用的,即使外界使用也要清楚此函数的功能,比如前面的__add_wait_queue()等三个函数,就只能在以加带关中断的自旋锁时才能调用,目的是省去重复的加锁。而add_wait_queue()等函数则更为稳重一些。  
  159. <p><br>  
  160. </p>  
  161. <p>或许你觉得不可思议,但waitqueue就是这么简单。下面我们来看看是怎样用它来实现completion的。</p>  
  162. <p><br>  
  163. </p>  
  164. <h1>waitqueue的使用——实现completion</h1>  
  165. <div><br>  
  166. </div>  
  167. <div>completion是一种建立在waitqueue之上的信号量机制,它的接口简单,功能更简单,是waitqueue之上最好的封装例子。</div>  
  168. <div><br>  
  169. </div>  
  170. <div><pre name="code" class="cpp">struct completion {  
  171.     unsigned int done;  
  172.     wait_queue_head_t wait;  
  173. };  
  174. </pre>completion的结构很简单,用done来进行计数,用wait保存等待队列。</div>  
  175. <div><br>  
  176. </div>  
  177. <div><pre name="code" class="cpp">#define COMPLETION_INITIALIZER(work) \  
  178.     { 0, __WAIT_QUEUE_HEAD_INITIALIZER((work).wait) }  
  179.   
  180. #define COMPLETION_INITIALIZER_ONSTACK(work) \  
  181.     ({ init_completion(&work); work; })  
  182.   
  183. #define DECLARE_COMPLETION(work) \  
  184.     struct completion work = COMPLETION_INITIALIZER(work)  
  185.   
  186. #ifdef CONFIG_LOCKDEP  
  187. # define DECLARE_COMPLETION_ONSTACK(work) \  
  188.     struct completion work = COMPLETION_INITIALIZER_ONSTACK(work)  
  189. #else  
  190. # define DECLARE_COMPLETION_ONSTACK(work) DECLARE_COMPLETION(work)  
  191. #endif  
  192.   
  193. static inline void init_completion(struct completion *x)  
  194. {  
  195.     x->done = 0;  
  196.     init_waitqueue_head(&x->wait);  
  197. }<pre name="code" class="cpp" style="margin-top: 4px; margin-right: 0px; margin-bottom: 4px; margin-left: 0px; background-color: rgb(240, 240, 240); "></pre><pre name="code" class="cpp" style="margin-top: 4px; margin-right: 0px; margin-bottom: 4px; margin-left: 0px; background-color: rgb(240, 240, 240); ">/* reinitialize completion */</pre>#define INIT_COMPLETION(x) ((x).done = 0)  
  198. <pre></pre>  
  199. 以上是completion结构的初始宏定义和初始化函数。我们又在其中看到了CONFIG_LOCKDEP,已经熟悉了。</pre></div>  
  200. <div><br>  
  201. </div>  
  202. <div><pre name="code" class="cpp"><pre name="code" class="cpp">/** 
  203.  * wait_for_completion: - waits for completion of a task 
  204.  * @x:  holds the state of this particular completion 
  205.  * 
  206.  * This waits to be signaled for completion of a specific task. It is NOT 
  207.  * interruptible and there is no timeout. 
  208.  * 
  209.  * See also similar routines (i.e. wait_for_completion_timeout()) with timeout 
  210.  * and interrupt capability. Also see complete(). 
  211.  */  
  212. void __sched wait_for_completion(struct completion *x)  
  213. {  
  214.     wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_UNINTERRUPTIBLE);  
  215. }  
  216.   
  217. static long __sched  
  218. wait_for_common(struct completion *x, long timeout, int state)  
  219. {  
  220.     might_sleep();  
  221.   
  222.     spin_lock_irq(&x->wait.lock);  
  223.     timeout = do_wait_for_common(x, timeout, state);  
  224.     spin_unlock_irq(&x->wait.lock);  
  225.     return timeout;  
  226. }  
  227.   
  228.   
  229. static inline long __sched  
  230. do_wait_for_common(struct completion *x, long timeout, int state)  
  231. {  
  232.     if (!x->done) {  
  233.         DECLARE_WAITQUEUE(wait, current);  
  234.   
  235.         wait.flags |= WQ_FLAG_EXCLUSIVE;  
  236.         __add_wait_queue_tail(&x->wait, &wait);  
  237.         do {  
  238.             if (signal_pending_state(state, current)) {  
  239.                 timeout = -ERESTARTSYS;  
  240.                 break;  
  241.             }  
  242.             __set_current_state(state);  
  243.             spin_unlock_irq(&x->wait.lock);  
  244.             timeout = schedule_timeout(timeout);  
  245.             spin_lock_irq(&x->wait.lock);  
  246.         } while (!x->done && timeout);  
  247.         __remove_wait_queue(&x->wait, &wait);  
  248.         if (!x->done)  
  249.             return timeout;  
  250.     }  
  251.     x->done--;  
  252.     return timeout ?: 1;  
  253. }</pre>  
  254. <pre></pre>  
  255. <br>  
  256. wait_for_completion()将线程阻塞在completion上。关键过程在计数值为0时调用do_wait_for_common阻塞。</pre></div>  
  257. <div>do_wait_for_common()首先用DECLARE_WAITQUEUE()定义一个初始化好的wait_queue_t,并调用__add_wait_queuetail()将节点加入等待队列尾部。然后调用signal_pending_state()检查线程信号与等待状态的情况,如果允许信号响应并且有信号阻塞在线程上,自然不必再阻塞了,直接返回-ERESTARTSYS。否则调用__set_current_state()设置线程状态(线程阻塞的状态分TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE,前者允许信号中断,后者则不允许),并调用schedule_timeout()将当前线程从就绪队列换出。注意completion会在被唤醒时检查计数是否可被占用,有时唤醒了却无法占用时只得再次阻塞。最后获得计数后调用__remove_wait_queue()将局部变量节点从等待队列中删除。<br>  
  258. <br>  
  259. </div>  
  260. <div>do_wait_for_common()最后一行的c语句不符合标准,这也是gcc扩展的一部分。timeout为0时返回1,否则返回timeout值。</div>  
  261. <div>schedule_timeout()的功能是使当前线程至少睡眠timeout个jiffies时间片,timeout值为MAX_SCHEDULE_TIMEOUT时无限睡眠。<span style="font-family:monospace"><span style="white-space:pre">返回值为0,如因响应信号而提前恢复,则返回剩余的timeout计数。</span></span></div>  
  262. <div><span style="font-family:monospace"><span style="white-space:pre"><br>  
  263. </span></span></div>  
  264. <div><span style="font-family:monospace"><span style="white-space:pre"></span></span><pre name="code" class="cpp">unsigned long __sched  
  265. wait_for_completion_timeout(struct completion *x, unsigned long timeout)  
  266. {  
  267.     return wait_for_common(x, timeout, TASK_UNINTERRUPTIBLE);  
  268. }  
  269.   
  270. int __sched wait_for_completion_interruptible(struct completion *x)  
  271. {  
  272.     long t = wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_INTERRUPTIBLE);  
  273.     if (t == -ERESTARTSYS)  
  274.         return t;  
  275.     return 0;  
  276. }  
  277.   
  278. unsigned long __sched  
  279. wait_for_completion_interruptible_timeout(struct completion *x,  
  280.                       unsigned long timeout)  
  281. {  
  282.     return wait_for_common(x, timeout, TASK_INTERRUPTIBLE);  
  283. }  
  284.   
  285. int __sched wait_for_completion_killable(struct completion *x)  
  286. {  
  287. <span style="white-space:pre">  </span>long t = wait_for_common(x, MAX_SCHEDULE_TIMEOUT, TASK_KILLABLE);  
  288. <span style="white-space:pre">  </span>if (t == -ERESTARTSYS)  
  289. <span style="white-space:pre">      </span>return t;  
  290. <span style="white-space:pre">  </span>return 0;  
  291. }</pre>wait_for_completion_timeout()使用带超时时间的阻塞。</div>  
  292. <div><span style="font-family:monospace"><span style="white-space:pre">wait_for_completion_interruptible()使用允许信号打断的阻塞。</span></span></div>  
  293. <div><span style="font-family:monospace"><span style="white-space:pre">wait_for_completion_interruptible_timeout()使用带超时时间的允许信号打断的阻塞。</span></span></div>  
  294. <div><span style="font-family:monospace"><span style="white-space:pre">wait_for_completion_killable()使用允许被杀死的阻塞。</span></span></div>  
  295. <div><span style="font-family:monospace"><span style="white-space:pre">四者都是wait_for_completion()的变种,通过wait_for_common()调用do_wait_for_common()实现。</span></span></div>  
  296. <div><span style="font-family:monospace"><span style="white-space:pre"><br>  
  297. </span></span></div>  
  298. <div><span style="font-family:monospace"><span style="white-space:pre"><br>  
  299. </span></span><pre name="code" class="cpp">void complete(struct completion *x)  
  300. {  
  301.     unsigned long flags;  
  302.   
  303.     spin_lock_irqsave(&x->wait.lock, flags);  
  304.     x->done++;  
  305.     __wake_up_common(&x->wait, TASK_NORMAL, 1, 0, NULL);  
  306.     spin_unlock_irqrestore(&x->wait.lock, flags);  
  307. }  
  308.   
  309. /* 
  310.  * The core wakeup function. Non-exclusive wakeups (nr_exclusive == 0) just 
  311.  * wake everything up. If it's an exclusive wakeup (nr_exclusive == small +ve 
  312.  * number) then we wake all the non-exclusive tasks and one exclusive task. 
  313.  * 
  314.  * There are circumstances in which we can try to wake a task which has already 
  315.  * started to run but is not in state TASK_RUNNING. try_to_wake_up() returns 
  316.  * zero in this (rare) case, and we handle it by continuing to scan the queue. 
  317.  */  
  318. static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,  
  319.             int nr_exclusive, int wake_flags, void *key)  
  320. {  
  321.     wait_queue_t *curr, *next;  
  322.   
  323.     list_for_each_entry_safe(curr, next, &q->task_list, task_list) {  
  324.         unsigned flags = curr->flags;  
  325.   
  326.         if (curr->func(curr, mode, wake_flags, key) &&  
  327.                 (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)  
  328.             break;  
  329.     }  
  330. }</pre>complete()唤醒阻塞的线程。通过调用__wake_up_common()实现。<br>  
  331. <br>  
  332. </div>  
  333. <p>这里curr->func()调用的一般是default_wake_function()。</p>  
  334. <p></p><pre name="code" class="cpp">int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,  
  335.               void *key)  
  336. {  
  337.     return try_to_wake_up(curr->private, mode, wake_flags);  
  338. }</pre>default_wake_function()将正睡眠的线程唤醒,调用try_to_wake_up()实现。try_to_wake_up()内容涉及TCB状态等问题,我们将其忽略。<p></p>  
  339. <p><br>  
  340. </p>  
  341. <p></p><pre name="code" class="cpp">void complete_all(struct completion *x)  
  342. {  
  343.     unsigned long flags;  
  344.   
  345.     spin_lock_irqsave(&x->wait.lock, flags);  
  346.     x->done += UINT_MAX/2;  
  347.     __wake_up_common(&x->wait, TASK_NORMAL, 0, 0, NULL);  
  348.     spin_unlock_irqrestore(&x->wait.lock, flags);  
  349. }</pre>complete_all()唤醒等待在completion上的所有线程。<p></p>  
  350. <p><br>  
  351. </p>  
  352. <p></p><pre name="code" class="cpp">bool try_wait_for_completion(struct completion *x)  
  353. {  
  354.     int ret = 1;  
  355.   
  356.     spin_lock_irq(&x->wait.lock);  
  357.     if (!x->done)  
  358.         ret = 0;  
  359.     else  
  360.         x->done--;  
  361.     spin_unlock_irq(&x->wait.lock);  
  362.     return ret;  
  363. }</pre>try_wait_for_completion()试图在不阻塞情况下获得信号量计数。<p></p>  
  364. <p><br>  
  365. </p>  
  366. <p></p><pre name="code" class="cpp">/** 
  367.  *  completion_done - Test to see if a completion has any waiters 
  368.  *  @x: completion structure 
  369.  * 
  370.  *  Returns: 0 if there are waiters (wait_for_completion() in progress) 
  371.  *       1 if there are no waiters. 
  372.  * 
  373.  */  
  374. bool completion_done(struct completion *x)  
  375. {  
  376.     int ret = 1;  
  377.   
  378.     spin_lock_irq(&x->wait.lock);  
  379.     if (!x->done)  
  380.         ret = 0;  
  381.     spin_unlock_irq(&x->wait.lock);  
  382.     return ret;  
  383. }</pre>completion_done()检查是否有线程阻塞。但这里实现过于简单,因为在返回0时也可能没有线程阻塞,也许只用在特殊情况下或者较为宽松的场合。<p></p>  
  384. <p><br>  
  385. </p>  
  386. <p><br>  
  387. <br>  
  388. <br>  
  389. <br>  
  390. <br>  
  391. <br>  
  392. <br>  
  393. <br>  
  394. <br>  
  395. </p>  
  396. <p><br>  
  397. </p>  
  398. <p><br>  
  399. </p>  
  400. <p><br>  
  401. </p>  
  402.   
  403. </pre> 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值