我是靠谱客的博主 潇洒火龙果,最近开发中收集的这篇文章主要介绍内核进程(四) —— 撤销内核进程实现,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

内核进程实现

本文章基于 Linux 2.6.11 编写

每个进程都有生命周期,对于用户态进程,当程序从 main() 函数中返回或直接调用 exit() ,由或者进程接收到了不能处理、忽视的信号时,进程就会被终止。当这些进程“死”了后,内核必须能够回收他们的资源,比如内存、打开的文件等等。

内核提供了两种终止进程的方式,系统调用 exit_group() 终止整个线程组,由内核函数 do_exit_group() 实现,标准库函数 exit() 就是以此实现;系统调用 exit() 终止线程组中的单个进程,由内核函数 do_exit() 实现,POSIX 库函数 pthread_exit() 以此实现。下面我们分 线程组终止进程终止 来讨论。

线程组的终止

线程组的终止描述的是内核对 由多个共享内存空间、文件表等资源的轻量级进程(struct task 实体)构成的线程组的回收工作。do_exit_group()内核函数其实现的入口,下面给出注解。

NORET_TYPE void do_group_exit(int exit_code)
{

    // 乐观加锁
	if (current->signal->flags & SIGNAL_GROUP_EXIT)
        // 已经开始执行进程组退出过程,将退出码作为本进程的退出码
		exit_code = current->signal->group_exit_code;
	else if (!thread_group_empty(current)) {
        // 线程组不为空
        // 线程组中信号处理是共享的
		struct signal_struct *const sig = current->signal;
		if (sig->flags & SIGNAL_GROUP_EXIT)
			/* 
             * 加锁再次判断,确认竞争
             */
			exit_code = sig->group_exit_code;
		else {
            // 保存退出码
			sig->flags = SIGNAL_GROUP_EXIT;
			sig->group_exit_code = exit_code;
            // 杀死其他线程
			zap_other_threads(current);
		}
	}

    // 杀死本进程,而不返回
	do_exit(exit_code);
}

通过判断线程组中的成员是否已经调用了过 do_group_exit(),内核使用 SIGNAL_GROUP_EXIT 来进行标记,防止多次杀死同一个线程组中的其他进程,线程组一定会共享 信号处理,所以我们可以将已发起线程组退出退出标志和进程退出码保存在 struct signal 数据结构中。

在内核保存标记和退出码后,调用 zap_other_threads() 来将其线程组成员杀死。

void zap_other_threads(struct task_struct *p)
{
	struct task_struct *t;

	p->signal->flags = SIGNAL_GROUP_EXIT;
	p->signal->group_stop_count = 0;

    /*线程组为空,直接返回*/
	if (thread_group_empty(p))
		return;

    /*遍历线程组中的其他成员*/
	for (t = next_thread(p); t != p; t = next_thread(t)) {
		/*
         * 已经处于退出状态
		 */
		if (t->exit_state)
			continue;

		/*
         * 非首领进程,在退出时不用向父进程发送信号(一般就是 SIGCHLD)        
		 */
		if (t != p->group_leader)
			t->exit_signal = -1;
		/*通过KILL信号来通知被强制退出的线程*/
		sigaddset(&t->pending.signal, SIGKILL);
        /*移除未决的其他非 KILL 的停止信号*/
		rm_from_queue(SIG_KERNEL_STOP_MASK, &t->pending);
		/*唤醒,处理信号,就会退出*/
		signal_wake_up(t, 1);
	}
}

关于 Unix 信号处理的 内核实现需要大量的篇幅来讲解,这里你仅需要知道,内核通过向 线程组其他成员发送 SIGKILL 信号来杀死他们。 内核将一个 SIGKILL 信号标记在未决(待处理)的信号集合中,并清除其他会引起进程停止被调度的未决信号,然后调用 signal_wake_up() 函数使目标进程能立马响应 SIGKILL 信号。该函数通过发送 CPU 间中断,使正在用户态运行的目标进程强制陷入内核态,在《中断实现》中我们提及过,当进程从中断例程中退出,恢复被中断的用户态上下文时,会检查和处理未决的信号,所以内核选择使用 中断例程为空的 RESCHEDULE_VECTOR 中断来完成这样的需求。这样所有的线程组成员在用户态响应 SIGKILL,并陷入内核执行 do_exit() 从而被杀死。

进程的终止

进程的终止描述的是内核对 struct task_struct 为单位的进程实体的回收工作,包括内存资源(页表)、IPC对象(System V 信号量)、文件表、文件系统等,要解说这些需要大量的篇幅,并可以另立主题,这里我们仅需要知道 do_exit() 会释放一次对这些资源的引用,如果没有其他路径或进程引用他们,那么就会被当即回收。我们主要讨论在进程在死亡时,对父进程和子进程产生的影响,这会使进程描述符所在的组织关系发生变化,以及进程最后一次调度和进程描述符的回收工作,在展开讲解时,我们将忽略进程跟踪的情况,以及粗略介绍退出时的信号处理,以便引入不必要的复杂性而不能把握整体的流程。

do_exit()

所有进程的退出都是由 do_exit()实现的,下面给出主干部分的源码和注解。

fastcall NORET_TYPE void do_exit(long code)
{
	struct task_struct *tsk = current;
	int group_dead;

    ...

	/*标记正在退出*/
	tsk->flags |= PF_EXITING;
	/*删除定时器*/
	del_timer_sync(&tsk->real_timer);

    ...
	/*解除页表和数据页*/
	exit_mm(tsk);
    /*关闭 System V 信号量*/
	exit_sem(tsk);
	/*关闭文件描述符*/
	__exit_files(tsk);
	/*关闭文件系统*/
	__exit_fs(tsk);

    ...

	tsk->exit_code = code;
    // 通知亲戚进程,调整组织结构
	exit_notify(tsk);

	BUG_ON(!(current->flags & PF_DEAD));
	schedule();
	BUG();
    ...
}

当进程关闭各种资源,并通知 亲属进程后,就进行一次主动调度,这是该进程最后一次运行,对该进程描述符和内存描述符的一次引用会在下一个被调度进程的内核路径 finish_task_switch() 中完成,见《完全公平调度》中的介绍。

exit_notify()

退出的进程可能存在子进程,所以内核必须考虑他们的寄养问题,以保证进程组织结构的完整性,并按照 POSIX 的规定进程退出时,必须以 Unix 信号的方式通知其父进程,这样以便使父进程能够收集其子进程退出信息,比如重新启动一个新的子进程来继续完成相关工作,另外值得一提的是,这种 Posix 约定是可以控制的,如果 struct task_structexit_signal 字段不为 -1 就会发送 这个字段对应的信号给其父进程,该信号一般都是 SIGCHLDexit_notify() 就是完成这些工作。

static void exit_notify(struct task_struct *tsk)
{
	int state;
	struct task_struct *parent_task, *t;
	struct list_head ptrace_dead, *_p, *_n;

    /*退出进程的未决信号处理*/
    if (signal_pending(tsk) && !(tsk->signal->flags & SIGNAL_GROUP_EXIT)
	    && !thread_group_empty(tsk)) {
        // 遍历线程组,找到一个没有执行退出,也没有要处理未决信号的线程来接纳
		for (t = next_thread(tsk); t != tsk; t = next_thread(t)) {
			if (!signal_pending(t) && !(t->flags & PF_EXITING)) {
				/*找到一个没有未决信号,且也没有退出线程唤醒*/
				recalc_sigpending_tsk(t);
				if (signal_pending(t))
					signal_wake_up(t, 0);
			}
		}
   	}

    ...
    // 找到一个进程去领养该退出进程的所有子进程
	forget_original_parent(tsk, &ptrace_dead);
    
    /*处理孤儿进程组*/
	parent_task = tsk->real_parent;
	if ((process_group(parent_task) != process_group(tsk)) &&
	    (parent_task->signal->session == tsk->signal->session) &&
	    will_become_orphaned_pgrp(process_group(tsk), tsk) &&
	    has_stopped_jobs(process_group(tsk))) {
        // 向进程组中所有成员发送信号
		__kill_pg_info(SIGHUP, (void *)1, process_group(tsk));
		__kill_pg_info(SIGCONT, (void *)1, process_group(tsk));
	}
    
    /*通知父进程*/

	if (tsk->exit_signal != -1 && thread_group_empty(tsk)) {
        // 退出时需要发出信号,且没有其他线程成员了。
		int signal = (tsk->parent == tsk->real_parent ?
				tsk->exit_signal : SIGCHLD);
        /*使用信号通知父进程,如果是 SIGCHLD 信号通知,且父进程忽略 SIGCHLD,则自动收割*/
		do_notify_parent(tsk, signal);
	}

    /*判断进程是直接退出还是变成一个僵死进程等待父进程回收他最后的状态*/
	if (tsk->exit_signal == -1 &&
	    (likely(tsk->ptrace == 0) ||
		 unlikely(tsk->parent->signal->flags & SIGNAL_GROUP_EXIT))) {
        /*直接退出*/
		state = EXIT_DEAD;
	} else {
        /*僵死状态,存在其他兄弟线程为退出或父进程不忽略 SIGCHLD */
		state = EXIT_ZOMBIE;
	}

	tsk->exit_state = state;
    ...

	/* 
     * 不需要 wait(),此处解除一次引用,切出后再解除一次引用就释放了
	 * 原初始值为2
	 * @see finish_task_switch()
	 */
	if (state == EXIT_DEAD)
		release_task(tsk);

	/* 标记 PF_DEAD 使 调度后可以是否一次引用. */
	preempt_disable();
	tsk->flags |= PF_DEAD;
}

该函数相对复杂,主要完成以下的工作:

  1. 处理属于该进程的未决 Unix 信号,简单提一下,Unix 信号可以分为两种发送方式,发送给线程组,或发送给线程组中的某个成员,发送给线程组的信号可以由线程组的任一个成员来处理,发送某个线程成员的信号一般都由该成员处理,换句话说信号只打断处理该信号的线程的用户态执行流,转而执行信号处理程序。进程退出时如果发现还有其他线程组成员存活,则将未决信号交付于其中一个成员继承,防止信号丢失。

  2. 寄养所有子进程,相当于托孤,自己要死了,希望有一个合适的人来照顾自己的孩子,在内核实现上就是给所有子进程指定一个新的父进程。通过 forget_original_parent(),下面我们会细讲。

  3. 处理孤儿进程组,因为根据 POSIX 的规定,一个进程退出使所在的进程组变为孤儿进组,如果孤儿进程组包含停止的进程(处于 TASK_STOPPED 状态),那么必须向进程组中所有成员进程先后发送 SIGHUPSIGCOND 信号。这里简单列出判断孤儿进程组函数实现,注意怎么遍历进程组,我们已经在 《进程组织结构》一文中介绍过do_each_task_pid() ... 宏的实现。

static int will_become_orphaned_pgrp(int pgrp, task_t *ignored_task)
{
	struct task_struct *p;
	int ret = 1;
    
    // 遍历进程组
	do_each_task_pid(pgrp, PIDTYPE_PGID, p) {
		if (p == ignored_task
				|| p->exit_state
				|| p->real_parent->pid == 1)
			continue;
		if (process_group(p->real_parent) != pgrp
			    && p->real_parent->signal->session == p->signal->session) {
            /* 该组中有一个进程,其父进程在属于同一个会话的另一个组中,
             * 这个进程组就不会成为孤儿进程组
             */
			ret = 0;
			break;
		}
        // 如果进程组里的所有进程的父进程都属于这个进程组 或 父进程和子进程处于不同会话
	} while_each_task_pid(pgrp, PIDTYPE_PGID, p);
	return ret;	
}
  1. 发送信号通知父进程,我们只需要了解,在某个应用程序的最后一个线程退出时,发送 SIGCHLD 信号给父进程这个一般情况即可。do_notify_parent() 用于向父进程发送信号,并附加一些退出进程的运行统计,即 struct siginfo info。注意当应用层将 SIGCHLD 信号的信号处理程序设置为 SIG_IGN,则不会发送该信号,这是 POSIX 的一个约定。

  2. 判定进程的最后运行状态,如果需要发送信号给父进程,那么进程就不能进行最后的释放,这种情况会交由父进程调用 wait() 系列调用获取最后的状态,所以设置一个处于死亡的中间状态 EXIT_ZOMBIE,即僵死状态,就是经常提及的僵死进程,只要父进程不掉用 wait() ,这个状态就会一直保持;否则就将设置为死亡状态 EXIT_DEAD

  3. 最后的回收,内核调用 release_task() 将处于 EXIT_DEAD 的进程从内核进程组织结构剥离,释放对描述符的引用,从而完成最后的回收工作,下面将展开讲解。

do_notify_parent()

需要注意的是,如果父进程忽略处理 SIGCHLD 那么调用该函数后,退出进程就变为了 TASK_DEAD 死亡状态,进而自动收割自己,不会经历 TASK_ZOMBIE 僵死状态。

void do_notify_parent(struct task_struct *tsk, int sig)
{
	struct siginfo info;
	unsigned long flags;
	struct sighand_struct *psig;

	BUG_ON(sig == -1);

	//构造信号信息
	...

	psig = tsk->parent->sighand;
	/*如果退出信号时 SIGCHLD 且 父进程不处理该信号,则不发送信号,切修改退出进程的 exit_signal 字段*/
	if (sig == SIGCHLD &&
	    (psig->action[SIGCHLD-1].sa.sa_handler == SIG_IGN ||
	     (psig->action[SIGCHLD-1].sa.sa_flags & SA_NOCLDWAIT))) {
		/*
         * 如果父进程不关系真正退出的子进程。
         * 对于 SIGCHLD 使用 SIG_IGN 通过 signal() 安装  或 使用 SA_NOCLDWAIT 标志 通过
         * sigaction() 安装,POSIX.1 定义了一个特殊的语义,我们应该被自动的收割
         * 而不是通过父进程调用  wait4() 来收割。
         *
         * 仅仅设置这些去告诉 do_exit() 子进程可以被清理而不会变成僵死进程,
         * 而不是让父进程把他当成一种神奇的信号处理句柄来做。
         * 即便是这样,我们仍然调用 __wake_up_parent() ,好使阻塞在 sys_wait4()
         * 的父进程返回 -ECHILD.
         *
         * 对于 SA_NOCLDWAIT 是否发送 SIGCHLD 这里的实现是 ———— 仍然发送。
         * 如果不想发送,就使用 SIG_IGN 代替
		 */
		tsk->exit_signal = -1;
		if (psig->action[SIGCHLD-1].sa.sa_handler == SIG_IGN)
			sig = 0;
	}
	if (sig > 0 && sig <= _NSIG)
		__group_send_sig_info(sig, &info, tsk->parent);
	__wake_up_parent(tsk, tsk->parent);
	spin_unlock_irqrestore(&psig->siglock, flags);
}

forget_original_parent()

主要完成遍历自己的所有子进程,为其找到一个合适的进程来寄养自己的子进程,首先查看是否有未退出的兄弟线程,也就是说如果多线程应用程序,如果其中的一个线程 fork() 了子进程,那么当这个线程退出时,由这个应用中的其他线程来领养其子进程;如果没有兄弟线程,则由 child_reaper1 号进程来收养这些孤儿,相当于政府办的孤儿所。这里暂时不考虑被退出进程跟踪的进程和其子进程被跟踪的情况,以减少复杂度。

static inline void forget_original_parent(struct task_struct * father,
					  struct list_head *to_release)
{
	struct task_struct *p, *reaper = father;
	struct list_head *_p, *_n;

    // 确定由谁来收养这些孤儿
	do {
		reaper = next_thread(reaper);
		if (reaper == father) {
            // 没有其他线程能收养,交付给 init 进程
			reaper = child_reaper;
			break;
		}
	} while (reaper->exit_state); // 如果其他线程也处于退出状态,则继续找

	/*
	 * 自己的普通孩子和被跟踪的孩子都需要寄养给别人
	 */
	list_for_each_safe(_p, _n, &father->children) {
        // 子进程以task->sibling链接到父进程的task->children上
		p = list_entry(_p,struct task_struct,sibling);
		if (father == p->real_parent) {
			/*修改真实父进程字段 p->real_parent = reaper;*/
			choose_new_parent(p, reaper, child_reaper);
            /*链接到收养进程的子进程链表中*/
			reparent_thread(p, father, 0);
		} else {
            ...
		}
        ...
	}
    ...
}

寄养“孤儿”(指定进程)到新的“家庭”(父进程),reparent_thread()

/*@param father 是原来的父进程,即即将退出的进程*/
static inline void reparent_thread(task_t *p, task_t *father, int traced)
{
	/* 被寄养过的进程退出一定是向(养)父进程发出 SIGCHLD 信号  */
	if (p->exit_signal != -1)
		p->exit_signal = SIGCHLD;

    ...

	if (likely(!traced)) {
		/* 链接到新父进程的孩子链表上 */
		list_del_init(&p->sibling);
		p->parent = p->real_parent;
		list_add_tail(&p->sibling, &p->parent->children);

		/* 如果该子进程退出时需要通知旧的父进程,那么也通知新的父进程 */
		if (p->exit_state == EXIT_ZOMBIE && p->exit_signal != -1 &&
		    thread_group_empty(p))
            // 如果这个进程没有兄弟线程,且已处于僵死状态,则通知新的父进程
            // 收割自己(父进程没有调用 wait() 就退出,子进程就会出现在这里)
			do_notify_parent(p, p->exit_signal);
		}
	}

	/* 孤儿进程组处理 */
	if ((process_group(p) != process_group(father)) &&
	    (p->signal->session == father->signal->session)) {
		int pgrp = process_group(p);
		if (will_become_orphaned_pgrp(pgrp, NULL) && has_stopped_jobs(pgrp)) {
			__kill_pg_info(SIGHUP, (void *)1, pgrp);
			__kill_pg_info(SIGCONT, (void *)1, pgrp);
		}
	}
}

主要有如下几样动作。

  1. 链接到养父进程的子进程链表中,与此同时,如果被寄养的进程已经处于僵死状态,且没有兄弟线程,那么就通知养父进程,以便尽快回收僵死进程。

  2. 一个进程属于孤儿进程组,但其父进程不属于孤儿进程组,那么当其父进程退出时,如果孤儿进程组内有停止的进程,则通知组内所有成员。

release_task()

进程退出最后的善后工作,最重要的就是从进程树 和 PID 哈希表上剥离,此时除非你有进程描述符的引用,否则你不能再找到该进程,即进程真正的“死亡”了。

void release_task(struct task_struct * p)
{
	int zap_leader;
	task_t *leader;
	struct dentry *proc_dentry;

repeat: 

	proc_dentry = proc_pid_unhash(p);
	
	...

	__exit_signal(p);
	__exit_sighand(p);
    // 从PIDHash表中删除,并解除父子进程的关系
	__unhash_process(p);

	/*
     * 如果我们是线程组中最后一个非领头线程,且领头线程处于僵死状态,则通知
     * 组领头线程的父进程
	 */
	zap_leader = 0;
	leader = p->group_leader;
	if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) {
		BUG_ON(leader->exit_signal == -1);
		/*如果 leader 的退出信号时 SIGCHLD 那么将自动收割*/
		do_notify_parent(leader, leader->exit_signal);
		/*
         * 如果我们是最后一个子线程,且领头线程已经退出,领头线程的父进程又忽略
         * SIGCHLD信号,那么我们是应该释放(执行release_task(leader))领头
         * 线程的人。
		 */
		zap_leader = (leader->exit_signal == -1);
	}

	/*补偿时间片给父进程之类的工作*/
	sched_exit(p);

	/*释放一次引用计数*/
	put_task_struct(p);

	/*自动收割领头线程*/
	p = leader;
	if (unlikely(zap_leader))
		goto repeat;
}

这里有个难点,就是当一个多线程引用程序退出时,如果领头线程先于兄弟线程退出,并且其父进程需要处理 SIGCHLD,那么根据 POSIX 约定,领头线程退出时,将发送 SIGCHLD 信号,如果父进程调用 wait() 回收了该领头线程的最后状态,那么势必回收掉所有的资源,包括 task 进程描述符内存,但是兄弟线程的 task->group_leader 还引用了,如果其他兄弟线程解引用该字段就会引起内核宕机。所以 Linux 内核的做法是,当出现这样的退出顺序时,无论领头进程退出时应该发送什么样的信号,一概都改为不发送信号仅变为僵死状态(参考 exit_notify() 中的 do_notify_parent() 处),当最后一个兄弟进程退出时,才检查是否发送 Unix 信号并通知父进程。

该函数中如果出现上述情况,就调用 do_notify_parent(),该函数的处理结果会是,如果发送的是 SIGCHLD 信号并且父进程忽略处理该信号,然后由本路径做最后的释放(即调用 release_task()),所以这里就会出现一次循环,由兄弟线程代替父进程收割领头线程。

内核通过 __unhash_process() 从进程各个组织结构中移除进程描述符的相应节点,其中的辅助函数和宏,在 《进程组织结构》一文中有详解。

static void __unhash_process(struct task_struct *p)
{
	nr_threads--;
	/*从 pid_hash[] 中移除*/
	detach_pid(p, PIDTYPE_PID);
	detach_pid(p, PIDTYPE_TGID);
	if (thread_group_leader(p)) {
		detach_pid(p, PIDTYPE_PGID);
		detach_pid(p, PIDTYPE_SID);
		...
	}
	/*从进程树和进程链表中移除*/
	REMOVE_LINKS(p);
}

收割退出子进程

如果进程退出时发送 SIGCHLD 信号,并且父进程不忽略该信号的处理,那么父进程将对退出进程进行最后的回收工作。根据 POSIX 约定,由命名为 wait() 系列的系统调用完成,比如 wait()waitpid()以及 wait4() 等。这些系统调用在内核中都是堆核心内核函数 do_wait() 的封装。

这个函数相当的复杂,我们只考虑收集僵死进程并执行最终释放的情况,这也是本文的主题内容,下面仅列出这部分的相关代码和注解。

static long do_wait(pid_t pid, int options, struct siginfo __user *infop,
		    int __user *stat_addr, struct rusage __user *ru)
{
	DECLARE_WAITQUEUE(wait, current);
	struct task_struct *tsk;
	int flag, retval;

	/*加入到等待队列*/
	add_wait_queue(&current->signal->wait_chldexit,&wait);
repeat:
	/*如果能匹配 pid 的进程存在,但暂时不可收割,则设置为真,遍历完所有子进程后,就会检查是否需要休眠*/
	flag = 0;
	/*设置为不可调度,但响应信号*/
	current->state = TASK_INTERRUPTIBLE;
	tsk = current;
	do {
		struct task_struct *p;
		struct list_head *_p;
		int ret;

		/*遍历当前线程的子进程*/
		list_for_each(_p,&tsk->children) {
			p = list_entry(_p,struct task_struct,sibling);

			/*筛选调用者期望收割的进程*/
			ret = eligible_child(pid, options, p);
			if (!ret)
				continue;

			switch (p->state) {
				...
			default:
				/*僵死进程是否能收割*/
			// case EXIT_ZOMBIE:
				if (p->exit_state == EXIT_ZOMBIE) {
					/* 多线程程序的领头线程先于兄弟线程退出时,不能立马收割 */
					if (ret == 2)
						goto check_continued;
					...
					/*收集僵死进程的统计信息,并执行最终释放*/
					retval = wait_task_zombie(p, (options & WNOWAIT),
						infop, stat_addr, ru);
					if (retval != 0)
						goto end;
					break;
				}
check_continued:
				flag = 1;
				...
				break;
			}
		}
		...
		/*遍历兄弟线程*/
		tsk = next_thread(tsk);
	} while (tsk != current);

	if (flag) {
		retval = 0;
		/*系统掉用者不希望休眠*/
		if (options & WNOHANG)
			goto end;
		/*休眠等待退出子进程的唤醒*/
		schedule();
		/*重新检查*/
		goto repeat;
	}
	/*没有子进程或期望子进程、进程组,则返回该错误*/
	retval = -ECHILD;
end:
	/*恢复可调度*/
	current->state = TASK_RUNNING;
	/*从等待队列中移除*/
	remove_wait_queue(&current->signal->wait_chldexit,&wait);
	if (infop) {
		/*如果成功收割,将收集退出进程的统计拷贝到用户空间*/
		...
	}
	return retval;
}

该函数主要遍历所有子进程,如果是多线程应用程序也包括兄弟线程的子进程,然后通过 eligible_child() 检查这些子进程是否匹配 系统调用者 期望收割的条件,比如,如果 pid==-1 则任何僵死的子进程都(如需详细了解此系统调用的参数,请参考《Unix环境高级编程》),。

最后

以上就是潇洒火龙果为你收集整理的内核进程(四) —— 撤销内核进程实现的全部内容,希望文章能够帮你解决内核进程(四) —— 撤销内核进程实现所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(53)

评论列表共有 0 条评论

立即
投稿
返回
顶部