进程管理

掌握不深,能力有限,仅作学习探讨,需在以后更深层次的去理解。

本篇主要是对于进程知识的一个梳理,顺便简介了一下线程:

进程是Unix操作系统最基本的抽象之一,一个进程就是处于执行期的程序(目标码存放在某种存储介质上)。

        执行线程,简称线程,实在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。

        内核调度的对象是线程,而不是进程。Linux系统的线程实现非常特别——它对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程罢了。

        进程的另一个名字是任务(task),Linux内核通常把进程也叫做任务。


1 进程描述符及任务结构

      内核把进程存放叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux.sched.h>中。进程描述符中包含一个具体进程的所有信息。

  进程描述符中包含的数据能完整的描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态,还有其它更多的信息(如下图)。


1.1 分配进程描述符

    在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端。这样做是为了让那些想x86这样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的位置,从而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info(如下图)。这个新的结构能使在汇编代码中计算其偏移变得相当容易。



    在x86上,thread_info结构在文件<asm/thread_info.h>中定义如下:     
struct thread_info {
    struct task_struct    *任务;
    struct exec_domain  *exec_domain;
    unsigned long           flags;
    unsigned long           status;
    __u32                      cpu;
    __s32                       preempt_count;
    mm_segment_t        addr_limit;
    struct restart_block   restart_block;
    unsigned long            previous_esp;
    __u8                        supervisor_stack[0];
};
    每个任务的thread_info结构在它的内核栈的尾端分配。结构中task域中存放的是指向该任务实际task_struct的指针。

1.2 进程描述符的存放

    内核通过一个惟一的进程标识值(process identification value)或PID来标识每个进程。PID的最大值默认为32768(short int短整型的最大值),其表示系统中允许同时存在的进程的最大数目。如果有需要的话,可以通过修改/proc/sys/kernel/pid_max来提高上限。
    内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找当前正在运行进程的进程描述符的速度就很重要了。硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构作处理。有的硬件体系结构可以拿出一个专门寄存器来存放当前进程task_struct的指针,用于加快访问速度。而像x86的体系结构(寄存器并不富余),就只能在内核的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。
    在x86系统上,current把栈指针后的13个有效位屏蔽掉,用来计算出thread_info的偏移。该操作通过current_thread_info()函数完成的。汇编代码如下:
movl  $-8192, %eax
andl   %esp, %eax
    这里假定栈的大小为8KN。当4KB的栈启用时,就要用4096,而不是8192。
    最后,current再从thread_info的task域中提取并返回task_struct的地址:
    current_thread_info()->task;
    对比一下这部分在PowerPC上的实现(IBM基于RISC的现代微处理器),我们可以发现当前task_struct的地址是保存在一个寄存器中的。也就是说,在PPC上,current宏只需把r2寄存器中的值返回就行了。与x86不一样,PPC有足够多的寄存器,所以它的实现有这样的余地。而访问进程描述符是一个重要的频繁操作,所以PPC的内核开发者觉得完全有必要为此使用一个专门的寄存器。

1.3 进程状态

    进程描述符中的state域描述了进程的当前状态(参见下图)。系统中的每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:
  • TASK_RUNNING(运行)——进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行惟一可能的状态;也可以应用到内核空间中正在执行的进程。
  • TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
  • TASK_UNINTERRUPTIBLE(不可中断)——除了不会因为接收到信号而被唤醒从而投入运行外,这个状态与可打断状态相同。这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不作响应,所以较之可中断状态,使用的较少。
  • TASK_ZOMBIE(僵死)——该进程已经结束了,但是其父进程还没有调用wait4()系统调用。为了父进程能够获知它的消息,子进程的描述符仍然被保留着。一旦父进程调用了wait4(),进程描述符就会被释放。
  • TASK_STOPPED(停止)——进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。

1.4 设置当前进程状态

     内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, state)函数:
     set_task_state(task, state); /*将任务‘task’的状态设置为‘state’*/
     该函数将指定的进程设置为指定的状态。必要的时候,它会设置内存屏障来强制其他处理器作重新排序(一般只在SMP系统中由此必要),否则,他等价于:
     task->state = state;
     方法set_current_state(state)和set_task_state(current, state)含义是等同的。

1.5 进程上下文

    可执行程序代码是进程的重要组成部分。这些代码从可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序调用执行了系统调用或者触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并有调度器做出了相应调整,否则在内核退出的时候,程序恢复用户空间继续执行。
    系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能进入内核执行——对内核的所有访问方式都必须通过这些接口。

1.6 进程家族树

    所有进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。

    系统中的每个进程必有一个父进程。相应地,每个进程也可以拥有零个或多个子进程。拥有同一个指向其父进程tast_struct、叫做parent的指针,还包含一个成为children的子进程链表。所以对于当前进程,可以通过下面的代码获得其父进程的进程描述符:

struct task_struct *my_parent = current->parent;
同样,也可以按以下方式依次访问子进程:

struct task_struct *task;
struct list_head *list;

list_for_each(list, ¤t->children) {
    task = list_entry(list, struct task_struct, sibling);
    /*task 现在指向当前的某个子进程*/
}
    init进程的进程描述符是作为init_task静态分配的。下面的代码可以很好地演示所有进程之间的关系:

struct task_struct *task;

for (task = current; task != &init_task; task = task->parent)
;
/*task 现在指向init*/

    任务队列是一个双向循环链表。对于给定的进程,获取链表的下一个进程:

    list_entry(task->tasks.next, struct task_struct, tasks)

    获取前一个进程的方法相同:

    list_entry(task->tasks.prev, struct task_struct, tasks)

    这两个例程分别通过next_task(task)宏和prev_task(task)宏实现。而实际上,for_each_process(task)宏提供了依次访问整个任务队列的能力。每次访问,任务指针都指向链表中的下一个元素:

    struct  task_struct *task;


    for_each_process(task) {

                      /*它打印出每一个任务的名称和PID*/

                      printk("%s[%d]\n", task->comm, task->pid);

    }

    需要注意的是,在一个拥有大量进程的系统中通过重复来遍历所有的进程是非常耗时的。因此,如果没有充足的理由的话别这样做。


2 进程创建

    Unix的进程创建很特别。许多其他的操作系统都提供了产生(spawn)的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。在Unix中,将上述步骤分解到两个单独的函数中去执行:fork()和exec()。fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程惟一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如挂起信号,它没有必要被继承)。exec()函数负责读取可执行文件并将其载入地址空间开始运行。把这两个函数组合起来使用的效果跟其他系统使用的单一函数的效果相似。


2.1 写时拷贝

    传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下——举例来说,fork()后立即调用exec()——它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。


2.2 fork()

    Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()、vfork()和__clone()库函数都根据各自需要的参数标志区调用clone()。然后由clone()去调用do_fork()。

    do_fork完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。copy_process()函数的工作过程:

调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述是完全相同。

  • 检查新创建的这个子进程后,当前用户所拥有的进程数目没有超出给他分配的资源的限制。
  • 现在,子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。进程描述符的成员值并不是继承而来的,而主要是统计信息。进程描述符中的大多数数据都是共享的。
  • 接下来,子进程的状态被设置为TASK_UNINTERRUPTIBLE以保证它不会投入运行。
  • copy_process()调用copy_flags以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还有没有调用exec()函数的PF_FORKNOEXEC标志被设置。
  • 调用get_pid()为新进程过去一个有效的PID。
  • 根据传递给clone的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
  • 让父进程和子进程平分剩余的时间片。
  • 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
    再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。


2.3 vfork()

    vfork()系统调用和fork()的功能相同,除了不拷贝父进程的页表项。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。
    vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。
  • 在调用copy_process()时,task_struct的vfork_done成员被设置为NULL。
  • 在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特殊地址。
  • 子进程开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。
  • 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。
  • 回到do_fork(),父进程醒来并返回。
     如果一切执行顺利,子进程在新的地址空间里运行而父进程也恢复了在原地址空间的运行。这样的实现,开销确实降低了,不过它的设计并不是优良的。

3 线程在Linux中的实现

    线程机制是现代编程技术中常用的一种抽象。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术(concurrent programming),在多处理器系统上,它能保证真正的并行处理(parallelism)。

    Linux实现线程的机制非常独特。从内核的角度来说,他并没有线程这个概念。Linux把所有线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有惟一隶属于自己的task_struct,所以在内核中,他看起来就像是一个普通的进程(只是该进程和其它一些进程共享某些资源,如地址空间)。

    上述线程机制的实现与Microsoft Windows或是Sun Solaris等操作系统的实现差异非常大。这些系统都在内核中提供了专门支持线程的机制(这些系统尝尝把线程称作轻量级进程,lightweight process)。“轻量级进程”这种叫法本身就概括了Linux在此处与其它系统的差异。在其它的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于Linux来说,它只是一种进程间共享资源的手段(Linux的进程本身就够轻了)。举个例子来说,假如我们有一个包含4个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建4个进程并分配4个普通的task_struct结构。建立这4个进程时指定它们共享某些资源就行了。

    线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

    clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

    上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符合信号处理程序。换个说话就是,新建的进程和它的父进程就是流行的所谓线程。

     对比一下,一个普通的fork()的实现就是:

    clone(SIGCHLD, 0);

     而vfork()的实现是:

    clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

    传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。下表列举了这些clone用到的的参数标志以及它们的作用,这些是在<linux/sched.h>中定义的。

clone()参数标志
参数标志含义
CLONE_FILES父子进程共享打开文件
CLONE_FS父子进程共享文件系统信息
CLONE_IDLETASK将PID设置为0(只供idle进程使用)
CLONE_NEWNS为子进程创建新的命名空间
CLONE_PARENT指定子进程与父进程拥有同一个父进程
CLONE_SETTID将TID回写至用户空间
CLONE_SETTLS为子进程差创建新的TLS
CLONE_SIGHAND父子进程共享信号处理函数
CLONE_SYSVSEM父子进程共享Sytem V SEM_UNDO语义
CLONE_THREAD父子进程放入相同的线程组
CLONE_VFORK调用vfork(),所以父进程准备睡眠等待子进程将其唤醒
CLONE_UNTRACED防止跟踪进程在子进程上强制执行CLONE_PTRACE
CLONE_STOP以TASK_STOPPED状态开始进程
CLONE_SETTLS为子进程创建新的TLS(thread-local storage)
CLONE_CHILD_CLEARTID清楚子进程的TID
CLONE_CHILD_SEETID设置子进程的TID
CLONE_PARENT_SETTID设置父进程的TID
CLONE_VM父子进程共享地址空间
  

内核线程

    内核经常需要在后台一些操作。这种任务可以通过内核线程(kernel thread)完成——独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。

    Linux确实会把一些任务交给内核线程去做,想pdflush和ksoftirqd这些任务就是明显的例子。这些线程在系统启动时由另外一些内核线程启动。实际上,内核线程也只能由其它内核线程创建。在现有内核线程中创建一个新的内核线程的方法如下:

    int kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)

    新的任务也是通过向普通的cline()系统调用传递特定的flags参数而创建的。在上面的函数返回时,父线程退出,并返回一个指向子线程task_struct的指针。子线程开始运行fn指向的函数,arg是运行时需要用到的参数。一个特殊的clone标志CLONE_KERNEL定义了内核线程常用到的参数标志:CLONE_FS、CLONE_FILES、CLONE_SIGHAND。大部分的内核线程把这个标志传递给它们的flags参数。


4 进程的终结

    当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程。
    一般来说,进程的析构发生在它调用exit()之后,既可能显式的调用这个系统调用,也可能隐式的从某个程序的主函数返回(C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号和异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠的do_exit()来完成,它要做下面这些繁琐的工作:
  • 首先,将task_struct中的标志成员设置为PF_EXITING
  • 其次,调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
  • 如果BSD的进程计账功是开启的,do_exit()调用acct_process()来输出计帐信息。
  • 然后调用_exit_mm()函数放弃进程占用的mm_struct,如果没有别的进程使用它们(也就是说,它们没有被共享),就彻底释放它们。
  • 接下来调用exit_sem()函数。如果进程排队等候IPC信号,它则离开队列。
  • 调用_exit_files()、_exit_fs()、exit_namespace()和exit_sighand(),已分别递减文件描述符、文件系统数据、进程名字空间和信号处理函数的引用计数。如果其中某些引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
  • 接着把存放在task_struct的exit_code成员中的任务退出代码置位exit()提供的代码中去,或者去完成任何其它由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
  • 调用exit_notify()向父进程发送信号,将子进程的父进程重新设置为线程组中的其它线程或init进程,并把进程状态设成TASK_ZOMBIE。
  • 最后,do_exit()调用schedule()切换到其它进程。因为处于TASK_ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。
     do_exit()的实现在kernel/exit.c文件中可以找到。
     至此,与进程相关联的所有资源都被释放掉了(假设进程是这些资源的惟一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于TASK_ZOMBIE状态。它占用的所有资源就是内核栈、thread_info结构和tast_strcut结构。此时进程存在的惟一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由金册所持有的剩余内存被释放,归还给系统使用。
    

4.1 删除进程描述符

    在调用了do_exit()之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。

    wait()这一族函数都是通过惟一(但是很复杂)的一个系统调用wait4()实现的。它的标准动作是挂起调用它的进程,直到其中一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。

    当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:

  • 首先,它调用free_uid()来减少该进程拥有者的进程使用计数。Linux用一个单用户高速缓存统计和记录每个用户占用的进程数目、文件数目。如果这些数目都将为0,表明这个用户没有任何进程和文件,那么这块缓存就可以销毁了。
  • 然后,release_task()调用unhash_process()从pidhash上删除该进程,同时也要从task_list中删除该进程。
  • 接下来,如果这个进程正在被ptrace跟踪、release_task()将跟踪的父进程重设为其最初的父进程并将它从ptrace list上删除。
  • 最后,release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占用的slab高速缓存。
    至此,进程描述符和所有进程独享的资源就全部释放掉了。

4.2 孤儿进程长成的进退维谷

    如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则的话这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的耗费内存 。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。在do_exit()中会有调用notify_parent(),该函数会通过forget_original_parent()来执行寻找父过程:
struct task_struct *p, *reaper = father;
struct list_head *list;

if (father->exit_signal != -1) 
    reaper = prev_thread(reaper);
else
    reaper = child_reaper;

if (reaper == father)
    reaper = child_reaper;
    这段代码将reaper设置为该进程所在的线程组内的其他进程。如果线程组内没有其他进程,它就将reaper设置为child_reaper,也就是init进程。现在,合适的父进程也已经找到了,只需要遍历所有子进程并为他们设置新的父进程:
list_for_each(list, &father->children) {
    p = list_entry(list, struct task_struct, sibling);
    reparent_thread(p, reaper, child_reaper);
}
list_for_each(listm &father->ptrace_children) {
    p = list_entry(list, struct task_struct, ptrace_list);
    reparent_thread(p, reaper, child_reaper);
}
    这段代码遍历了两个链表:子进程链表和ptrace子进程链表,给每个子进程设置新的父进程。这两个链表同时存在的原因很有意思,它也是2.6内核的一个新特性。当一个进程被跟踪是,它被暂时设定为调试进程的子进程。此时如果它的父进程退出了,系统会为它和它的所有兄弟重新找一个父进程。在以前的内核中,这就需要遍历系统所有的进程来找这些子进程。现在的解决办法是在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程——用两个相关链表减轻了遍历带来的消耗。
    一旦系统给进程成功的找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来等待其子进程,清除所有与其相关的僵死进程。

5 进程小结

    我们讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系。然后讨论了Linux如何存放和表示进程(用task_struct和thread_info),如何创建进程(通过clone()和fork()),如何把新的执行映像装入到地址空间(通过exec()系统调用族),如何表示进程的层次关系,父进程又是如何手机其后代的信息(通过wait()系统调用族),以及进程最终如何死亡(强制或自愿地调用exit())。
    进程是最基本、最重要的一种抽象,位于每个现代操作系统的核心位置,也是最终导致我们拥有操作系统的根源(通过执行程序)。
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值