linux kernel 工作队列

朋友 , 你相信 , 一只蝴蝶在北京拍拍翅膀 , 将使得纽约几个月后出现比狂风还厉害的龙卷风吗 ? 看过那部经典的影片蝴蝶效应的朋友们一定会说 , 这不就是蝴蝶效应吗 . 没错 . 蝴蝶效应其实是混沌学理论中的一个概念 . 它是指对初始条件敏感性的一种依赖现象 . 蝴蝶效应的原因在于蝴蝶翅膀的运动 , 导致其身边的空气系统发生变化 , 并引起微弱气流的产生 , 而微弱气流的产生又会引起它四周空气或其它系统产生相应的变化 , 由此引起连锁反应 , 最终导致其它系统的极大变化 .

自从 1979 年 12 月麻省理工的洛仑兹大侠在美国科学促进会上作了关于蝴蝶效应的报告之后 , 从此蝴蝶效应很快风靡全球 , 其迷人的美学色彩和深刻的科学内涵令许多人着迷 , 激动 , 同时发人深省 . 蝴蝶效应被引入了各个领域 , 比如军事 , 比如政治 , 比如经济 , 再后来也被引入到了企业管理 , 甚至我们的人生历程里也存在 . 当然 Linux 中也不会放过如此有哲学魅力的理论 . 从本质上来说 , 蝴蝶效应给人一种对未来行为不可预测的危机感 . 而 Linux 内核代码中这种感觉更是强烈 , 几乎到了无处不在的程度 . 很多函数 , 特别是那种做初始化的函数 , 你根本就不知道它在干什么 , 只有当你在未来某个时刻 , 看到了另一个函数 , 你才会回过头来看 , 原来当初是这个函数设置了初始条件 . 假如你改变了初始条件 , 那么后来你某个地方的某个函数的某个行为就会发生改变 . 但问题是 , 你并不知道这个行为将在午夜 12 点发生还是在下午 3 点半发生 .

是不是觉得很玄 ? 像思念一样玄 ? 那好 , 我们来看点具体的 , 比如 935 行 , INIT_DELAYED_WORK (). 这是一张新面孔 . 同志们大概注意到了 , 在 hub 这个故事里 , 我们的讲解风格略有变化 , 对于那些旧的东西 , 对于那些在 usb-storage 里面讲过很多次的东西 , 我们不会再多提 , 但是对于新鲜的东西 , 我们会花大把的笔墨去描摹 .

所以本节我们就用大把的笔墨来讲述老百姓自己的故事 . 就讲这一行 ,935 行 .INIT_DELAYED_WORK () 是一个宏 , 我们给它传递了两个参数 .&hub->leds 和 led_work. 对设备驱动熟悉的人不会觉得 INIT_DELAYED_WORK () 很陌生 , 其实战争那会儿就有这个宏了 , 只不过从 2.6.20 的内核开始这个宏做了改变 , 原来这个宏是三个参数 , 后来改成了两个参数 , 所以经常在网上看见一些同志抱怨说最近某个模块编译失败了 , 说什么 make 的时候遇见这么一个错误 :

error: macro "INIT_DELAYED_WORK " passed 3 arguments, but takes just 2

当然更为普遍的看到下面这个错误 :

error: macro "INIT_WORK" passed 3 arguments, but takes just 2

于是就让我们来仔细看看 INIT_WORK 和 INIT_DELAYED_WORK . 其实前者是后者的一个特例 , 它们涉及到的就是传说中的工作队列 . 这两个宏都定义于 include/linux/workqueue.h 中 :

79 #define INIT_WORK(_work, _func) /

80 do { /

81 (_work)->data = (atomic_long_t) WORK_DATA_INIT(); /

82 INIT_LIST_HEAD(&(_work)->entry); /

83 PREPARE_WORK((_work), (_func)); /

84 } while (0)

85

86 #define INIT_DELAYED_WORK (_work, _func) /

87 do { /

88 INIT_WORK(&(_work)->work, (_func)); /

89 init_timer(&(_work)->timer); /

90 } while (0)

有时候特怀念谭浩强那本书里的那些例子程序 , 因为那些程序都特简单 , 不像现在看到的这些 , 动不动就是些复杂的函数复杂的数据结构复杂的宏 , 严重挫伤了我这样的有志青年的自信心 . 就比如眼下这几个宏吧 , 宏里边还是宏 , 一个套一个 , 不是说看不懂 , 因为要看懂也不难 , 一层一层展开 , 只不过确实没必要非得都看懂 , 现在这样一种朦胧美也许更美 , 有那功夫把这些都展开我还不如去认认真真学习三个代表呢 . 总之 , 关于工作队列 , 就这么说吧 ,Linux 内核实现了一个内核线程 , 直观一点 ,ps 命令看一下您的进程 ,

localhost:/usr/src/linux-2.6.22.1/drivers/usb/core # ps -el

F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD

4 S 0 1 0 0 76 0 - 195 - ? 00:00:02 init

1 S 0 2 1 0 -40 - - 0 migrat ? 00:00:00 migration/0

1 S 0 3 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/0

1 S 0 4 1 0 -40 - - 0 migrat ? 00:00:00 migration/1

1 S 0 5 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/1

1 S 0 6 1 0 -40 - - 0 migrat ? 00:00:00 migration/2

1 S 0 7 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/2

1 S 0 8 1 0 -40 - - 0 migrat ? 00:00:00 migration/3

1 S 0 9 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/3

1 S 0 10 1 0 -40 - - 0 migrat ? 00:00:00 migration/4

1 S 0 11 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/4

1 S 0 12 1 0 -40 - - 0 migrat ? 00:00:00 migration/5

1 S 0 13 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/5

1 S 0 14 1 0 -40 - - 0 migrat ? 00:00:00 migration/6

1 S 0 15 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/6

1 S 0 16 1 0 -40 - - 0 migrat ? 00:00:00 migration/7

1 S 0 17 1 0 94 19 - 0 ksofti ? 00:00:00 ksoftirqd/7

5 S 0 18 1 0 70 -5 - 0 worker ? 00:00:00 events/0

1 S 0 19 1 0 70 -5 - 0 worker ? 00:00:00 events/1

5 S 0 20 1 0 70 -5 - 0 worker ? 00:00:00 events/2

5 S 0 21 1 0 70 -5 - 0 worker ? 00:00:00 events/3

5 S 0 22 1 0 70 -5 - 0 worker ? 00:00:00 events/4

1 S 0 23 1 0 70 -5 - 0 worker ? 00:00:00 events/5

5 S 0 24 1 0 70 -5 - 0 worker ? 00:00:00 events/6

5 S 0 25 1 0 70 -5 - 0 worker ? 00:00:00 events/7

瞅见最后这几行了吗 ,events/0 到 events/7,0 啊 7 啊这些都是处理器的编号 , 每个处理器对应其中的一个线程 . 要是您的计算机只有一个处理器 , 那么您只能看到一个这样的线程 ,events/0, 您要是双处理器那您就会看到多出一个 events/1 的线程 . 哥们儿这里 Dell PowerEdge 2950 的机器 ,8 个处理器 , 所以就是 events/0 到 events/7 了 .

那么究竟这些 events 代表什么意思呢 ? 或者说它们具体干嘛用的 ? 这些 events 被叫做工作者线程 , 或者说 worker threads, 更确切的说 , 这些应该是缺省的工作者线程 . 而与工作者线程相关的一个概念就是工作队列 , 或者叫 work queue. 工作队列的作用就是把工作推后 , 交由一个内核线程去执行 , 更直接的说就是如果您写了一个函数 , 而您现在不想马上执行它 , 您想在将来某个时刻去执行它 , 那您用工作队列准没错 . 您大概会想到中断也是这样 , 提供一个中断服务函数 , 在发生中断的时候去执行 , 没错 , 和中断相比 , 工作队列最大的好处就是可以调度可以睡眠 , 灵活性更好 .

就比如这里 , 如果我们将来某个时刻希望能够调用 led_work() 这么一个我们自己写的函数 , 那么我们所要做的就是利用工作队列 . 如何利用呢 ? 第一步就是使用 INIT_WORK() 或者 INIT_DELAYED_WORK () 来初始化这么一个工作 , 或者叫任务 , 初始化了之后 , 将来如果咱们希望调用这个 led_work() 函数 , 那么咱们只要用一句 schedule_work() 或者 schedule_delayed_work() 就可以了 , 特别的 , 咱们这里使用的是 INIT_DELAYED_WORK (), 那么之后我们就会调用 schedule_delayed_work(), 这俩是一对 . 它表示 , 您希望经过一段延时然后再执行某个函数 , 所以 , 咱们今后会见到 schedule_delayed_work() 这个函数的 , 而它所需要的参数 , 一个就是咱们这里的 &hub->leds, 另一个就是具体自己需要的延时 .&hub->leds 是什么呢 ?struct usb_hub 中的成员 ,struct delayed_work leds, 专门用于延时工作的 , 再看 struct delayed_work, 这个结构体定义于 include/linux/workqueue.h:

35 struct delayed_work {

36 struct work_struct work;

37 struct timer_list timer;

38 };

其实就是一个 struct work_struct 和一个 timer_list, 前者是为了往工作队列里加入自己的工作 , 后者是为了能够实现延时执行 , 咱们把话说得更明白一点 , 您看那些 events 线程 , 它们对应一个结构体 ,struct workqueue_struct, 也就是说它们维护着一个队列 , 完了您要是想利用工作队列这么一个机制呢 , 您可以自己创建一个队列 , 也可以直接使用 events 对应的这个队列 , 对于大多数情况来说 , 都是选择了 events 对应的这个队列 , 也就是说大家都共用这么一个队列 , 怎么用呢 ? 先初始化 , 比如调用 INIT_DELAYED_WORK (), 这么一初始化吧 , 实际上就是为一个 struct work_struct 结构体绑定一个函数 , 就比如咱们这里的两个参数 ,&hub->leds 和 led_work() 的关系 , 就最终让 hub_leds 这个 struct work_struct 结构体和函数 led_work() 相绑定了起来 , 您问怎么绑定的 ? 您瞧 ,struct work_struct 也是定义于 include/linux/workqueue.h:

24 struct work_struct {

25 atomic_long_t data;

26 #define WORK_STRUCT_PENDING 0 /* T if work item pending execution */

27 #define WORK_STRUCT_FLAG_MASK (3UL)

28 #define WORK_STRUCT_WQ_DATA_MASK (~WORK_STRUCT_FLAG_MASK)

29 struct list_head entry;

30 work_func_t func;

31 };

瞅见最后这个成员 func 了吗 , 初始化的目的就是让 func 指向 led_work(), 这就是绑定 , 所以以后咱们调用 schedule_delayed_work() 的时候 , 咱们只要传递 struct work_struct 的结构体参数即可 , 不用再每次都把 led_work() 这个函数名也给传递一次 , 一旦绑定 , 人家就知道了 , 对于 led_work(), 那她就嫁鸡随鸡 , 嫁狗随狗 , 嫁混蛋随混蛋了 . 您大概还有一个疑问 , 为什么只要这里初始化好了 , 到时候调用 schedule_delayed_work() 就可以了呢 ? 事实上 ,events 这么一个线程吧 , 它其实和 hub 的内核线程一样 , 有事情就处理 , 没事情就睡眠 , 也是一个死循环 , 而 schedule_delayed_work() 的作用就是唤醒这个线程 , 确切的说 , 是先把自己的这个 struct work_struct 插入 workqueue_struct 这个队列里 , 然后唤醒昏睡中的 events. 然后 events 就会去处理 , 您要是有延时 , 那么它就给您安排延时以后执行 , 您要是没有延时 , 或者您设了延时为 0, 那好 , 那就赶紧给您执行 . 咱这里不是讲了两个宏吗 , 一个 INIT_WORK(), 一个 INIT_DELAYED_WORK (), 后者就是专门用于可以有延时的 , 而前者就是没有延时的 , 这里咱们调用的是 INIT_DELAYED_WORK (), 不过您别美 , 过一会您会看见 INIT_WORK() 也被使用了 , 因为咱们 hub 驱动中还有另一个地方也想利用工作队列这么一个机制 , 而它不需要延时 , 所以就使用 INIT_WORK() 进行初始化 , 然后在需要调用相关函数的时候调用 schedule_work() 即可 . 此乃后话 , 暂且不表 .

基本上这一节咱们就是介绍了 Linux 内核中工作队列机制提供的接口 , 两对函数 INIT_DELAYED_WORK () 对 schedule_delayed_work(),INIT_WORK() 对 schedule_work().

关于工作队列机制 , 咱们还会用到另外两个函数 , 它们是 cancel_delayed_work(struct delayed_work *work) 和 flush_scheduled_work(). 其中 cancel_delayed_work() 的意思不言自明 , 对一个延迟执行的工作来说 , 这个函数的作用是在这个工作还未执行的时候就把它给取消掉 . 而 flush_scheduled_work() 的作用 , 是为了防止有竞争条件的出现 , 虽说哥们儿也不是很清楚如何防止竞争 , 可是好歹大二那年学过一门专业课 , 数字电子线路 , 尽管没学到什么有用的东西 , 怎么说也还是记住了两个专业名词 , 竞争与冒险 . 您要是对竞争条件不是很明白 , 那也不要紧 , 反正基本上每次 cancel_delayed_work 之后您都得调用 flush_scheduled_work() 这个函数 , 特别是对于内核模块 , 如果一个模块使用了工作队列机制 , 并且利用了 events 这个缺省队列 , 那么在卸载这个模块之前 , 您必须得调用这个函数 , 这叫做刷新一个工作队列 , 也就是说 , 函数会一直等待 , 直到队列中所有对象都被执行以后才返回 . 当然 , 在等待的过程中 , 这个函数可以进入睡眠 . 反正刷新完了之后 , 这个函数会被唤醒 , 然后它就返回了 . 关于这里这个竞争 , 可以这样理解 ,events 对应的这个队列 , 人家本来是按部就班的执行 , 一个一个来 , 您要是突然把您的模块给卸载了 , 或者说你把你的那个工作从工作队列里取出来了 , 那 events 作为队列管理者 , 它可能根本就不知道 , 比如说它先想好了 , 下午 3 点执行队列里的第 N 个成员 , 可是您突然把第 N-1 个成员给取走了 , 那您说这是不是得出错 ? 所以 , 为了防止您这种唯恐天下不乱的人做出冒天下之大不韪的事情来 , 提供了一个函数 ,flush_scheduled_work(), 给您调用 , 以消除所谓的竞争条件 , 其实说竞争太专业了点 , 说白了就是防止混乱吧 .

Ok, 关于这些接口就讲到这里 , 日后咱们自然会在 hub 驱动里见到这些接口函数是如何被使用的 . 到那时候再来看 . 这就是蝴蝶效应 . 当我们看到 INIT_WORK/INIT_DELAYED_WORK () 的时候 , 我们是没法预测未来会发生什么的 . 所以我们只能拭目以待 . 又想起了那句老话 , 大学生活就像被 , 如果不能反抗 , 那就只能静静的去享受它 .

from:http://blog.csdn.net/fudan_abc/archive/2007/08/20/1751565.aspx

工作队列(work queue )是另外一种将工作推后执行的形式,它和前面讨论的tasklet有所不同。工作队列可以把工作推后,交由一个内核线程去执行,也就是说,这个下半部分可以在进程上下文中执行。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的就是工作队列允许被重新调度甚至是睡眠。

那么,什么情况下使用工作队列,什么情况下使用 tasklet 。如果推后执行的任务需要睡眠,那么就选择工作队列。如果推后执行的任务不需要睡眠,那么就选择 tasklet 。另外,如果需要用一个可以重新调度的实体来执行你的下半部处理,也应该使用工作队列。它是唯一能在进程上下文运行的下半部实现的机制,也只有它才可以睡眠。这意味着在需要获得大量的内存时、在需要获取信号量时,在需要执行阻塞式的 I/O 操作时,它都会非常有用。如果不需要用一个内核线程来推后执行工作,那么就考虑使用 tasklet 。

  1. 工作、工作队列和工作者线程

如前所述,我们把推后执行的任务叫做工作( work ),描述它的数据结构为 work_struct ,这些工作以队列结构组织成工作队列( workqueue ),其数据结构为 workqueue_struct ,而工作线程就是负责执行工作队列中的工作。系统默认的工作者线程为 events, 自己也可以创建自己的工作者线程。

  1. 表示工作的数据结构

工作用 <linux/workqueue.h> 中定义的 work_struct 结构表示:

struct work_struct{

unsigned long pending; /* 这个工作正在等待处理吗? */

struct list_head entry; /* 连接所有工作的链表 */

void (*func) (void *); /* 要执行的函数 */

void *data; /* 传递给函数的参数 */

void *wq_data; /* 内部使用 */

struct timer_list timer; /* 延迟的工作队列所用到的定时器 */

};

这些结构被连接成链表。当一个工作者线程被唤醒时,它会执行它的链表上的所有工作。工作被执行完毕,它就将相应的 work_struct 对象从链表上移去。当链表上不再有对象的时候,它就会继续休眠。

3. 创建推后的工作

要使用工作队列,首先要做的是创建一些需要推后完成的工作。可以通过 DECLARE_WORK 在编译时静态地建该结构:

DECLARE_WORK(name, void (*func) (void *), void *data);

这样就会静态地创建一个名为 name ,待执行函数为 func ,参数为 data 的 work_struct 结构。

同样,也可以在运行时通过指针创建一个工作:

INIT_WORK(struct work_struct *work, woid(*func) (void *), void *data);

这会动态地初始化一个由 work 指向的工作。

4. 工作队列中待执行的函数

工作队列待执行的函数原型是:

void work_handler(void *data)

这个函数会由一个工作者线程执行,因此,函数会运行在进程上下文中。默认情况下,允许响应中断,并且不持有任何锁。如果需要,函数可以睡眠。需要注意的是,尽管该函数运行在进程上下文中,但它不能访问用户空间,因为内核线程在用户空间没有相关的内存映射。通常在系统调用发生时,内核会代表用户空间的进程运行,此时它才能访问用户空间,也只有在此时它才会映射用户空间的内存。

5. 对工作进行调度

现在工作已经被创建,我们可以调度它了。想要把给定工作的待处理函数提交给缺省的 events 工作线程,只需调用

schedule_work(&work) ;

work 马上就会被调度,一旦其所在的处理器上的工作者线程被唤醒,它就会被执行。

有时候并不希望工作马上就被执行,而是希望它经过一段延迟以后再执行。在这种情况下,可以调度它在指定的时间执行:

schedule_delayed_work(&work, delay);

这时, &work 指向的 work_struct 直到 delay 指定的时钟节拍用完以后才会执行。

6. 工作队列的简单应用

# include < linux / module . h >
# include < linux / init . h >
# include < linux / workqueue . h >

static struct workqueue_struct * queue = NULL ;
static struct work_struct work ;

static void work_handler ( struct work_struct * data )
{
printk ( KERN_ALERT "work handler function./n" );
}

static int __init test_init ( void )
{
queue = create_singlethread_workqueue ( "helloworld" ); /* 创建一个单线程的工作队列*/
if (! queue )
goto err ;

INIT_WORK (& work , work_handler );
schedule_work (& work );

return 0 ;
err :
return - 1 ;
}

static void __exit test_exit ( void )
{
destroy_workqueue ( queue );
}
MODULE_LICENSE ( "GPL" );
module_init ( test_init );
module_exit ( test_exit );

from:http://www.lupaworld.com/26540/viewspace-85167.html


Linux中Workqueue机制分析

走入 Linux 的殿堂已经有一年有余了,在这里我想将 Linux 的各种实现机制分析一遍,一方面对自己来说也是温故而知新,另一方面,促进大家的交流,最好能够给大家一些抛砖引玉的启迪。我是硬件出身,搞硬件已经好多年了,从是专门软件开发也接近两年了,在这一段时间内我越发认为软硬件协同设计是未来发展的主流,软硬件的界限越来越模糊,软硬件的设计思想是相通的,实现方法是各异的,实现的结果上当然也存在较大差别,因此,很有必要做好软硬件的协同设计。本着这样的想法,我想将我所认识的 Linux 分析一遍,特别是一些我认为精华和重要的机制,另外在讨论过程中,我会插入一些其他的 OS 实现机制,进行对比分析,我把这一类 blog 文章划归为“ Linux 机制分析”,希望大家支持。

什么是 workqueue ?

Linux 中的 Workqueue 机制就是为了简化内核线程的创建。通过调用 workqueue 的接口就能创建内核线程。并且可以根据当前系统 CPU 的个数创建线程的数量,使得线程处理的事务能够并行化。

workqueue 是内核中实现简单而有效的机制,他显然简化了内核 daemon 的创建,方便了用户的编程,

Workqueue 机制的实现

Workqueue 机制中定义了两个重要的数据结构,分析如下:

1、 cpu_workqueue_struct 结构。该结构将 CPU 和内核线程进行了绑定。在创建 workqueue 的过程中, Linux 根据当前系统 CPU 的个数创建 cpu_workqueue_struct 。在该结构主要维护了一个任务队列,以及内核线程需要睡眠的等待队列,另外还维护了一个任务上下文,即 task_struct 。

2、 work_struct 结构是对任务的抽象。在该结构中需要维护具体的任务方法,需要处理的数据,以及任务处理的时间。该结构定义如下:

struct work_struct {

unsigned long pending;

struct list_head entry; /* 将任务挂载到 queue 的挂载点 */

void (*func)(void *); /* 任务方法 */

void *data; /* 任务处理的数据 */

void *wq_data; /* work 的属主 */

strut timer_list timer; /* 任务延时处理定时器 */

};

当用户调用 workqueue 的初始化接口 create_workqueue 或者 create_singlethread_workqueue 对 workqueue 队列进行初始化时,内核就开始为用户分配一个 workqueue 对象,并且将其链到一个全局的 workqueue 队列中。然后 Linux 根据当前 CPU 的情况,为 workqueue 对象分配与 CPU 个数相同的 cpu_workqueue_struct 对象,每个 cpu_workqueue_struct 对象都会存在一条任务队列。紧接着, Linux 为每个 cpu_workqueue_struct 对象分配一个内核 thread ,即内核 daemon 去处理每个队列中的任务。至此,用户调用初始化接口将 workqueue 初始化完毕,返回 workqueue 的指针。

在初始化 workqueue 过程中,内核需要初始化内核线程,注册的内核线程工作比较简单,就是不断的扫描对应 cpu_workqueue_struct 中的任务队列,从中获取一个有效任务,然后执行该任务。所以如果任务队列为空,那么内核 daemon 就在 cpu_workqueue_struct 中的等待队列上睡眠,直到有人唤醒 daemon 去处理任务队列。

Workqueue 初始化完毕之后,将任务运行的上下文环境构建起来了,但是具体还没有可执行的任务,所以,需要定义具体的 work_struct 对象。然后将 work_struct 加入到任务队列中, Linux 会唤醒 daemon 去处理任务。

上述描述的 workqueue 内核实现原理可以描述如下:
点击看大图

在 Workqueue 机制中,提供了一个系统默认的 workqueue 队列—— keventd_wq ,这个队列是 Linux 系统在初始化的时候就创建的。用户可以直接初始化一个 work_struct 对象,然后在该队列中进行调度,使用更加方便。

Workqueue 编程接口

序号

接口函数

说明

1

create_workqueue

用于创建一个 workqueue 队列,为系统中的每个 CPU 都创建一个内核线程。输入参数:

@name : workqueue 的名称

2

create_singlethread_workqueue

用于创建 workqueue ,只创建一个内核线程。输入参数:

@name : workqueue 名称

3

destroy_workqueue

释放 workqueue 队列。输入参数:

@ workqueue_struct :需要释放的 workqueue 队列指针

4

schedule_work

调度执行一个具体的任务,执行的任务将会被挂入 Linux 系统提供的 workqueue —— keventd_wq 输入参数:

@ work_struct :具体任务对象指针

5

schedule_delayed_work

延迟一定时间去执行一个具体的任务,功能与 schedule_work 类似,多了一个延迟时间,输入参数:

@work_struct :具体任务对象指针

@delay :延迟时间

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值