Linux内核设计与实现第三章学习笔记

文章目录

  • Linux内核设计与实现第三章学习笔记
    • 进程
    • 进程描述符及任务结构
      • 分配进程描述符
      • 进程的状态
    • 进程创建
      • 写时拷贝
      • fork
    • 线程在Linux中的实现
      • 创建线程
      • 内核线程
    • 进程终结
      • 删除进程描述符
      • 孤儿进程
    • 第三章小结

Linux内核设计与实现第三章学习笔记

进程

进程就是处于执行期的程序,通常进程还包括一些资源:打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址空间及一个或多个执行线程、存放全局变量的数据段等

实际上,进程就是正在执行的程序代码的实时结果,程序本身并不是进程,进程是处于执行期间的程序以及相关的 资源的总称

线程:内核调度的对象是线程,不是进程,每个线程都有独立的程序计数器、进程栈和一组进程寄存器。

在Linux系统中,通常调用fork系统调用复制一个现有进程来创建一个全新的进程。调用fork的进程称之为父进程,新产生的进程称之为子进程。该调用结束时,返回两次,一次回到父进程,一次回到新产生的子进程。现代Linux系统中,fork系统调用实际上是由clone来实现的

最终进程通过exit系统调用结束,该函数会终结进程并把占用的资源释放掉,父进程可以使用wait查看子进程是否死亡。进程退出后被设置为僵尸态,直到父进程调用wait为止

进程描述符及任务结构

分配进程描述符

Linux系统通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的,2.6内核之前,进程的task_struct被放置在内核栈的末端,这样做是为了快速计算出task_struct地址

对于现在使用slab分配器动态生成task_struct,只需要在栈底或栈顶创建一个struct thread_info,该结构体里有一个指向进程描述符的指针

进程的状态

  • TASK_RUNNING:正在运行或者在运行队列中等待执行
  • TASK_INTERRUPTIBLE:可打断状态,进程被阻塞,等待某些条件达成,内核会把进程状态设置为运行。处于此状态的进程也会因为接收到信号提前被唤醒并随时准备投入运行
  • TASK_UNINTERRUPTIBLE:不可被阻塞,除了接收到信号也不会被唤醒外,这个状态和可打断状态相同
  • __TASK_TRACED:被其他进程跟踪
  • __TASK_STOPPED:进程停止执行

进程创建

fork()通过拷贝当前进程创建一个子进程

子进程与父进程的区别

  • PID
  • PPID
  • 某些资源和统计量:挂起的信号

写时拷贝

传统的fork直接复制所有资源,如果子进程立马调用exev族函数,那么之前的拷贝就前功尽弃,所以才有了写时复制

让父子进程指向同一个内容,只有真正需要写入的时候才会复制,如果子进程立马执行exec族函数,那就没必要复制资源了,所以fork()的实际开销只有复制父进程的页表以及子进程创建唯一的进程描述符

fork

Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父子进程需要共享的资源

fork()、vfork()和__clone()根据各自的需要的参数标志去调用clone(),然后由clone()调用do_fork()

do_fork()在kernel/fork.c文件里,该函数调用copy_process()函数,copy_process()函数的工作内容:

  1. 调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这个时候的子进程还是和父进程一模一样的
  2. 检查并确保子进程被创建后,当前用户所拥有的进程数目没有超过资源的限制
  3. 子进程着手把自己和父进程区分开来,进程描述符内的许多成员都要被清0或设置为初始值
  4. 子进程的状态被设置为TASK_UNINTERRUPTIBLE,确保子进程不会被投入运行
  5. copy_process()函数调用copy_flags()以更新task_struct的flags成员。这时候把进程是否拥有超级用户权限的F_SUPERPRIV标志被清0,表明进程还没有执行exev()函数的PF_FORKNOEXEC标志被设置
  6. 调用alloc_pid()为新进程分配一个有效的PID
  7. 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间
  8. 最后,返回一个指向子进程的指针

再回到do_fork()函数,如果copy_process函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程先运行,因为子进程一般都是用于执行exec()函数,这样可以避免写时复制的额外开销

线程在Linux中的实现

每个线程都有唯一隶属于自己的task_struct,因此在内核看来,线程就是一个普通的进程。

假设有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个指向四个线程的指针的进程描述符,该描述符负责描述像地址空间、打开的文件这样的共享资源,线程再去独占它的资源

在Linux中则是创建四个进程并分配四个task_struct结构,建立这四个进程时指定它们共享某些资源

创建线程

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

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

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

对比一下,fork()则是

clone(SIGCHLD, 0);
/*
 * cloning flags:
 */
#define CSIGNAL         0x000000ff      /* signal mask to be sent at exit */
#define CLONE_VM        0x00000100      /* set if VM shared between processes */
#define CLONE_FS        0x00000200      /* set if fs info shared between processes */
#define CLONE_FILES     0x00000400      /* set if open files shared between processes */
#define CLONE_SIGHAND   0x00000800      /* set if signal handlers and blocked signals shared */
#define CLONE_PIDFD     0x00001000      /* set if a pidfd should be placed in parent */
#define CLONE_PTRACE    0x00002000      /* set if we want to let tracing continue on the child too */
#define CLONE_VFORK     0x00004000      /* set if the parent wants the child to wake it up on mm_release */
#define CLONE_PARENT    0x00008000      /* set if we want to have the same parent as the cloner */
#define CLONE_THREAD    0x00010000      /* Same thread group? */
#define CLONE_NEWNS     0x00020000      /* New mount namespace group */
#define CLONE_SYSVSEM   0x00040000      /* share system V SEM_UNDO semantics */
#define CLONE_SETTLS    0x00080000      /* create a new TLS for the child */
#define CLONE_PARENT_SETTID     0x00100000      /* set the TID in the parent */
#define CLONE_CHILD_CLEARTID    0x00200000      /* clear the TID in the child */
#define CLONE_DETACHED          0x00400000      /* Unused, ignored */
#define CLONE_UNTRACED          0x00800000      /* set if the tracing process can't force CLONE_PTRACE on this clone */
#define CLONE_CHILD_SETTID      0x01000000      /* set the TID in the child */
#define CLONE_NEWCGROUP         0x02000000      /* New cgroup namespace */
#define CLONE_NEWUTS            0x04000000      /* New utsname namespace */
#define CLONE_NEWIPC            0x08000000      /* New ipc namespace */
#define CLONE_NEWUSER           0x10000000      /* New user namespace */
#define CLONE_NEWPID            0x20000000      /* New pid namespace */
#define CLONE_NEWNET            0x40000000      /* New network namespace */
#define CLONE_IO                0x80000000      /* Clone io context */
/* Flags for the clone3() syscall. */
#define CLONE_CLEAR_SIGHAND 0x100000000ULL /* Clear any signal handler and reset to SIG_DFL. */
#define CLONE_INTO_CGROUP 0x200000000ULL /* Clone into a specific cgroup given the right permissions. */
/*
 * cloning flags intersect with CSIGNAL so can be used with unshare and clone3
 * syscalls only:
 */
#define CLONE_NEWTIME   0x00000080      /* New time namespace */

内核线程

内核线程和普通的进程的区别在于内核线程没有独立的地址空间(mm指针 = NULL),它们只在内核空间运行,从不切换到用户态运行。内核线程和普通进程一样可以被调度,也可以被抢占

从现有内核线程中创建一个新的内核线程的方法如下:

struct task_struct *kthread_create(int (*threadfn)(void *data),
				   void *data,
				   const char namefmt[], ...)

新的任务是由kthread内核进程通过clone()系统调用创建的,新的进程将运行threadfn()函数,给其传递的参数为data。进程会被命名为namefmt,namefmt接受可变参列表格式化参数

新创建的进程处于不可运行状态,如果不通过调用wake_UP_process()明确地唤醒它,它不会主动运行。

创建一个进程并让它运行起来,可以调用kthread_run()

#define kthread_run(threadfn, data, namefmt, ...)			   \
({									   \
	struct task_struct *__k						   \
		= kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
	if (!IS_ERR(__k))						   \
		wake_up_process(__k);					   \
	__k;								   \
})

其实只是简单的调用kthread_create()和wake_up_process()

内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出传递给kthread_create()函数返回的task_struct地址

int kthread_stop(struct task_struct *k)

进程终结

一般来说,进程的析构是自身引起的。它发生在进程调用exit()系统调用时,也可能从某个程序的主函数返回。当进程接受到它不能处理也不能忽略的信号或异常时,它还可能被动地结束,不管进程是如何结束的,该任务大部分都要靠do_exit()来完成,do_exit()完成以下繁琐的工作

  1. 将task_struct中的标志成员设置为PF_EXITING
  2. 调用del_timer_sync()函数任一内核定时器,确保没有定时器在排队,也没有定时器处理程序在运行
  3. 如果BSD的进程记账功能是开启的,do_exit()调用acct_up_date_intefrals()来输出记账信息
  4. 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们,就彻底释放它们
  5. 接下来调用sem_exit()函数,如果进程在排队等候IPC信号,则它离开队列
  6. 调用exit_files()和exit_fs(),分别递减文件描述符、文件系统的引用计数。如果没有引用计数的数值变为0,则表示此时没有其他进程在使用响应的资源,此时可以释放
  7. 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他内核机制规定的退出动作。退出代码存放在这里供父进程随时检索
  8. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者init进程,并把进程状态(存放在task_struct结构的exit_state)设置为EXIT_ZOMBIE
  9. do_exit()调用schedule()切换到新的进程

至此,进程相关联的所有资源都被释放掉了,进程处于不可运行的EXIT_ZOMBIL状态,也没有地址空间给它运行。此时还占用的只有内核栈、thread_info结构和task_struct结构。

它现在还存在的唯一目的就是向它的父进程提供信息,随后它剩余的资源全部被释放

删除进程描述符

父进程可以通过wait()来获取子进程的PID,此外,调用该函数时提供的指针还会包含子函数退出时的退出代码

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

  1. 它调用__exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid(),从pidhash上删除该进程,同时也要从任务列表中删除该进程
  2. __exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录
  3. 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程
  4. release_task()调用put_task_struct()释放进程内核栈和thread_info所占的页,并释放task_struct占用的slab高速缓存

孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会退出的时候永远处于僵尸态,白白的占用资源。

这个问题解决之法便是:在子进程当前的线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程,do_exit()中会调用exit_notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来进行寻父之旅

以下代码位于kernel/exit.c

/*
 * When we die, we re-parent all our children.
 * Try to give them to another thread in our thread
 * group, and if no such member exists, give it to
 * the child reaper process (ie "init") in our pid
 * space.
 */
static struct task_struct *find_new_reaper(struct task_struct *father)
	__releases(&tasklist_lock)
	__acquires(&tasklist_lock)
{
	struct pid_namespace *pid_ns = task_active_pid_ns(father);
	struct task_struct *thread;

	thread = father;
	while_each_thread(father, thread) {
		if (thread->flags & PF_EXITING)
			continue;
		if (unlikely(pid_ns->child_reaper == father))
			pid_ns->child_reaper = thread;
		return thread;
	}

	if (unlikely(pid_ns->child_reaper == father)) {
		write_unlock_irq(&tasklist_lock);
		if (unlikely(pid_ns == &init_pid_ns))
			panic("Attempted to kill init!");

		zap_pid_ns_processes(pid_ns);
		write_lock_irq(&tasklist_lock);
		/*
		 * We can not clear ->child_reaper or leave it alone.
		 * There may by stealth EXIT_DEAD tasks on ->children,
		 * forget_original_parent() must move them somewhere.
		 */
		pid_ns->child_reaper = init_pid_ns.child_reaper;
	}

	return pid_ns->child_reaper;
}

以上代码试图找到子进程所在线程组内的其他进程,如果线程组内没有其他进程,它找到并返回的是init进程,那么这个时候只需要遍历所有的子进程给它们设置新的父进程,于是乎

reaper = find_new_reaper(father);
list_for_each_entry(p, &father->children, sibling) {
	for_each_thread(p, t) {
		t->real_parent = reaper;
		BUG_ON((!t->ptrace) != (t->parent == father));
		if (likely(!t->ptrace))
			t->parent = t->real_parent;

以上代码是说:A进程(father)死了,那么A进程的所有子进程(father->children)都变成了孤儿进程,因此调用find_new_reaper找到养父(reaper,find_new_reaper()返回的时候,reaper已经被赋值为init进程),然后遍历A进程的子进程,把它们都过继给init进程

碍于笔者学识有限,学习笔记写的实在拙劣,这一部分强烈建议去参考一下大佬的文章,进程托孤

笔者注:

reaper:收割者,这里的意思是给子进程找一个收尸的进程

第三章小结

本章讨论了进程的一般特性,它为何如此重要,以及进程与线程之间的关系,然后讨论了Linux如何存放和表示进程(task_struct和thread_info),如何创建进程,如何把新的执行映像装入地址空间,父进程如何收集后代的信息,进程最终如何消亡

笔者后续会不断更新学习笔记,喜欢可以关注一下~

你可能感兴趣的:(Linux内核设计与实现,操作系统,操作系统)