Linux内核设计与实现(一)| 进程管理

从内核出发

为什么有了IPC(进程间通信)机制

进程间有了通信出自于OS内核可分为两大阵营

  • 单内核:内核的简单实现,大致就是每个内核服务都运行在同一个大的地址空间上,那么进程间的通信就微不足道了,直接调用函数就好了,跟用户态的应用程序没什么区别,所以性能高的同时很简单
  • 微内核:微内核并不作为一个单独的大过程来实现,内核的功能被划分为很多个小的过程,每个小过程你都可以称为一个服务器,除了特殊情况,这些服务器基本都运行在用户空间,独立运行在各自的地址空间;所以不可能像单内核的时候直接进行调用,而是通过消息传递处理微内核通信:IPC,服务器之间通过IPC进行通信,这样做好的好处就是当一个服务器失效不会祸及另一个,模块化的设计允许一个服务器为了另一个服务器而换出。

微内核在某些OS的改良

由于IPC机制的开销要高于函数调用,并且会涉及频繁的上下文切换,因此消息传递需要一定的周期,所以实际上用微内核的OS都是将大部分或全部服务器位于内核,这样就可以直接调用函数了,Win和MAC的内核都是基于此

Linux与Unix的显著差异

  • Linux支持动态加载内核模块。尽管Linux内核也是单内核,可是允许在需要的时候动态地卸除和加载部分内核代码。
  • Linux支持对称多处理(SMP)机制,尽管许多Unix的变体也支持SMP,但传统的Unix并不支持这种机制。
  • Linux内核可以抢占(preemptive)。与传统的Unix变体不同,Linux内核具有允许在内核运行的任务优先执行的能力。在其他各种Unix产品中,只有Solaris和IRIX支持抢占,但是大多数Unix内核不支持抢占。
  • Linux对线程支持的实现比较有意思:内核并不区分线程和其他的一般进程。对于内核来说,所有的进程都一样——只不过是其中的一些共享资源而已。
  • Linux提供具有设备类的面向对象的设备模型、热插拔事件,以及用户空间的设备文件系统( sysfs)。
  • Linux忽略了一些被认为是设计得很拙劣的Unix 特性,像STREAMS,它还忽略了那些难以实现的过时标准。
  • Linux体现了自由这个词的精髓。现有的Linux特性集就是Linux公开开发模型自由发展的结果。如果一个特性没有任何价值或者创意很差,没有任何人会被迫去实现它。相反的,针对变革,Linux 已经形成了一种值得称赞的态度:任何改变都必须要能通过简洁的设计及正确可靠的实现来解决现实中确实存在的问题。于是,许多出现在某些Unix变种系统中,那些出于市场宣传目的或没有普遍意义的一些特性,如内核换页机制等都被毫不迟疑地摒弃了。

内核开发的特点

相对于用户空间内应用程序的开发,内核开发有一些独特之处。尽管这些差异并不会使开发内核代码的难度超过开发用户代码,但它们依然有很大不同。

这些特点使内核成了一只性格迥异的猛兽。一-些常用的准则被颠覆了,而又必须建立许多全新的准则。尽管有许多差异一目了然(人人都知道内核可以做它想做的任何事),但还是有一些差异晦暗不明。最重要的差异包括以下几种:

  • 内核编程时既不能访问C库也不能访问标准的C头文件。
  • 内核编程时必须使用GNU C。
  • 内核编程时缺乏像用户空间那样的内存保护机制:用户程序试图去进行以此非法访问会被结束整个进程,但是如果发起者是内核自己那就很难控制了
  • 内核编程时难以执行浮点运算。
  • 内核给每个进程只有一个很小的定长堆栈。
  • 由于内核支持异步中断、抢占和SMP,因此必须时刻注意同步和并发。
  • 要考虑可移植性的重要性。

内联函数

内联函数是指函数在被调用位置进行展开,这样做可以消除函数回调和返回带来的开支,不过这样会使代码边长,使用更多的内存和指令缓存;所以对于一个很长的函数并且没有时间的限制还会被反复调用,那么并不能成为内联函数

内核的内存不分页

内核中的内存都不分页。也就是说,你每用掉一个字节,物理内存就减少一个字节。所以,在你想往内核里加入什么新功能的时候,要记住这一点。

内核为什么要注意同步和并发

内核很容易产生竞争条件。和单线程的用户空间程序不同,内核的许多特性都要求能够并发地访问共享数据,这就要求有同步机制以保证不出现竞争条件,特别是:

  • Linux是抢占多任务操作系统。内核的进程调度程序即兴对进程进行调度和重新调度。内核必须和这些任务同步。
  • Linux内核支持对称多处理器系统(SMP)。所以,如果没有适当的保护,同时在两个或两个以上的处理器上执行的内核代码很可能会同时访问共享的同一个资源。
  • 中断是异步到来的,完全不顾及当前正在执行的代码。也就是说,如果不加以适当的保护,中断完全有可能在代码访问资源的时候到来,这样,中段处理程序就有可能访问同一资源。
  • Linux内核可以抢占。所以,如果不加以适当的保护,内核中一段正在执行的代码可能会被另外一段代码抢占,从而有可能导致几段代码同时访问相同的资源。

进程管理

1.进程

  • 进程

进程就是处于执行期的程序,但是又不局限于一段可执行的代码,而是包含着其他资源。像打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程( thread of execution),当然还包括用来存放全局变量的数据段等。实际上,进程就是正在执行的程序代码的实时结果。内核需要有效而又透明地管理所有细节。

程序本身可不是进程,因为完全有可能两个进程同属于一个程序;又有可能两个并存的进行可以共享打开的文件、地址空间之类的资源

进程的两种虚拟机制

  • 虚拟处理器:给进程一种自己在独享处理器的假象
  • 虚拟内存:让进程在分配和管理内存时觉得自己拥有整个系统的内存资源
  • 线程

执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。在Linux中的线程很特殊,我们更愿称为特殊的进程。注意线程可以共享虚拟内存,但是每个线程有各自的虚拟处理器

在多处理器的系统上,能够真正做到并行处理

Linux中的进程创建

在Linux中,进程一旦被创建就开始存过,这个行为一般是调用fork函数的结果,他会让系统复制一份现有的进程来创建一个全新的进程,调用fork的进程称为父进程,新产生的为子进程。调用后返回调用位置,父进程恢复执行、子进程开始执行,所以fork函数的调用会在内核之间返回两次:一次回到父进程,另一次回到新产生的子进程

创建好的进程一般是为了立刻执行新的不同的程序,接着会调用exec函数,为此进程分配自己的地址空间,并将程序加载进来,在现代OS中一般fork是由clone函数进行调用

最终进程执行结束由exit函数,将其占用的资源释放掉,父进程可通过调用wait4函数查询子进程是否终结,即可引发出等待通知的机制,进程退出后被设置为僵死状态,直到父进程调用wait或waitpid为止

注意:这几个wait函数名字不一样但是功能大致都是返回终结进程的状态

2.进程描述符及任务(进程)结构

  • 双向任务链表

内核把进程的列表放在叫做任务队列的双向链表中,链表每个节点都是一个task_struct,称为进程描述符的结构,描述符包含了一个具体进程的所有信息

  • task_struct

一个进程描述符大概有1.7KB在32位的情况下,里面包含有,打开的文件、进程的地址空间、挂起的信号、进程的状态
在这里插入图片描述

2.1 分配进程描述符

  • 概述

Linux通过分配器进行描述符的分配,这样能达到对象复用和缓存着色的目的;每个描述符会放在它们内核栈的尾端,这样为了兼容寄存器较少的硬件体系能够通过栈指针直接算出描述符的位置和方便在汇编代码中以获取偏移量;

为了避免使用额外的寄存器专门记录,现在的slab分配器都是通过动态的生成描述符,只要在栈底(向下添加)或栈顶(向上添加)创建一个新的结构struct thread info通过预先分配和重复使用可以使得进程创建十分迅速
在这里插入图片描述

  • struct thread info的代码
struct thread_info {
	struct task_struct	*task;		//指向该任务实际的描述符的指针
	struct exec_domain	*exec_domain;
	__u32	flags;
	__u32	status;
	__u32	cpu;
	int	preempt_count ;
	mm_segment_t	addr_limit;
	struct	restart_block restart_block ;
	void	sysenter_return;
	int	uaccess_err;
};

2.2 进程描述符的存放

  • 标识进程:PID

进程通过一个唯一的进程标示值或者PID来标识每个进程;PID是一个short int类型的数,最大默认值为32768(但可以增加高达400万的值,这个400万就是定义的最大值),内核将PID与描述符进行捆绑

在当今越来越多的大型服务器需要更多的进程,。这个值越小,转一圈就越快,本来数值大的进程比数值小的进程迟运行,但这样一来就破坏了这一原则。如果确实需要的话,可以不考虑与老式系统的兼容,由系统管理员通过修改/proc/sys/kernel/pid_max来提高上限。

如何得到文件描述符

得到描述符的操作一般由current宏来负责,在不同的硬件上会有不同的实现

  • 前面我们提到将X86的硬件体系,一般是采用计算偏移值的方式得到;
  • 有的硬件则是通过拿出一个专门的寄存器来存放当前正在运行的描述符指针

2.3 进程状态

  • TASK_RUNNING (运行)

进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。

  • TASK_INTERRUPTIBLE(可中断)

进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。

  • TASK_UNINTERRUPTIBLE(不可中断)

除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态曰,使用得较少。

  • _TASK_TRACED

被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。

  • _TASK_STOPPED(停止)

进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。

进程间状态转换
在这里插入图片描述

2.4 设置当前进程状态

内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, state)函数;

set_task_state(task, state) ;/*将任务task的状态设置为state */

2.5 进程上下文

  • 概述

进程在执行中,可执行代码是非常重要的,这些代码需要载入到进程的地址空间所执行,当程序调用执行了系统调用或触发某个异常,则会陷入内核空间,我们称此为“内核在代表进程执行”并处于进程上下文,此时上下文的current宏是有效的(中断上下文就没效了,因为系统不代表进程执行了,是中断处理程序在执行),也就意味着可能有优先级更高的进程需要执行让调度器进行调整;如果没有这种情况的发生,内核退出后会恢复在用户空间进行执行

系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。

2.6 进程家族树(进程组)

  • 概述

所有进程都是PID为1的init进程(该进程的描述符是通过静态分配的)的后代,系统中每一个进程必有一个父进程,相应的每个进程也可以拥有零个或多个子进程,相同父进程的进程称为兄弟进程,这种进程关系的会放在描述符中进行维护

  • parent指针和children链表

每个描述符中都包含一个指向其父进程的描述符,称为parent指针,还包含一个为children的子进程链表

//对于当前进程,可以通过下面的代码获得其父进程的进程描述符:
struct task_struct		*my_parent = current->parent;
//同样,也可以按以下方式依次访问子进程:
struct task_struct *task ;
struct list_head *list;

list_for_each ( list,&current- >children) {
	//指向某个子进程
	task = list_entry(list, struct task_struct,sibling);
}
  • 进程间的遍历获取
//进程间的关系
struct task_struct *task ;
/* task现在指向init */
for (task = current; task != &init_task; task = task->parent)

//后一个进程
list_entry (task->tasks.next,struct task_struct,tasks)
//获取前一个进程的方法与之相同:
list_entry(task->tasks.prev,struct task_struct, tasks)

//循环遍历整个任务队列,大量任务的时候不会这样做的
struct task_struct *task ;

for_each_process (task) {
	/*它打印出每一个任务的名称和 PID*/
	printk ( "s[d]ln" , task->comm,task->pid);
}

3.进程创建

  • 概述

某些OS创建进程是直接进行产生的机制,开辟新的地址空间直接创建直接用;

而类unix的系统都是通过两个函数fork和exec来实现

  • 通过fork函数,得到一个子进程,子进程和父进程区别在于:不同的PID、PPID(父进程的ID)和某些资源的统计量(挂起的信号等)
  • exec函数将可执行文件加载到地址空间开始执行

3.1 写时拷贝

传统拷贝

传统的fork就如上面介绍的那样实现很简单直接把所有资源复制给新创建的线程,拷贝的数据也许并不共享,但是拷贝出来的新进程打算立即执行一个新的映像,那么所有拷贝就会失效

Linux的写时拷贝

Linux的fork通过写时拷贝页,该技术可以推迟甚至免除拷贝数据的技术,内核此时并不复制整个进程的地址空间,而是让父进程子进程共享同一个拷贝

在写入时数据才会进行复制,在此之前都是以只读方式共享,让实际的页拷贝发生在写入的时候才发生,无需写入也就无需复制了

3.2 fork()函数

fork的开销

fork函数的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符,一般我们要创建一个进程肯定有任务让他立马执行,这样就是避免了拷贝大量根本用不上的数据,优化快速执行的能力

fork的实现

在Linux中通过clone函数系统调用fork,调用通过一系列的参数指明父子进程需要共享的资源,大部分的fork库函数和clone库函数都需要底层调用do_fork函数,该函数完成了创建中的大部分工作,该函数调用copy_process函数,让进程开始运行

copy_process函数的作用

  1. 调用dup函数为进程创建一个内核栈、thread_info结构和描述符,这些值与当前进程的值相同,此时子进程和父进程完全相同
  2. 检查并确保当前用户所拥有的进程数目没有超过给其分配的资源数
  3. 子进程进行与父进程的分离,将子进程的描述符中的很多属性将被清零或者恢复初始化,不是继承而来的属性不做修改。但大多数据仍不被修改
  4. 子进程设置状态为不可中断,保证不会接任务执行
  5. copy_process调用copy_flags更新描述符的flag属性,表示进程是否有root权力的属性被清零,表示进程还没有执行的exec函数的标志PF FORKNOEXEC被设置
  6. 调用alloc_pid为新进程分配一个有效的PID
  7. 根据传递给上层clone函数的参数,copy_process会拷贝或者打开或共享相应的文件、地址空间、信号等,一般情况下这么资源会被进程中的线程共享概
  8. copy_process函数做收尾并返回一个指向子进程的指针
  9. 成功返回到do_fork函数,那么新创建的子进程会被内核有意的首先执行,因为子进程可以立马开始调用exec函数从而减少拷贝的额外开销,如果父进程首先执行,有可能会开始向地址空间写入,写入也就代表着拷贝

3.3 vfork()函数

  • 概述

该函数大致和fork函数功能一致,除了有一项:它不会拷贝父进程的页表项;不过等Linux支持写时拷贝页表项,这个函数也就彻底没用了,执行过程也不多加介绍,有兴趣可自行了解

4.线程在Linux中的实现

特殊的Linux线程

为什么说是特殊的呢?因为在Linux中不存在线程的概念,Linux把所有的线程都当做进程来实现,所以内核并没有准备特别的调度算法和数据结构去标识线程,相反线程被别的进程视为可以共享资源的进程,每个线程都有自己的描述符,在内核中,看起来是一个普通的进程,只不过与其他线程共享地址空间罢了

在Window一类的OS中线程都有专门的的语义和独立的机制,而在Linux中被称为轻量级进程,对于Linux而言它是一种进程间共享资源的手段

举个例子来说,假如我们有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建四个进程并分配四个普通的task_sturct结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。

4.1 创建线程

  • 概述

线程的创建其实跟进程一样,只不过在调用clone函数传递的参数需要指明共享的资源(父子共享地址空间、文件系统资源、文件描述符和信号处理程序等)

//线程实现
clone (CLONE_VM | CLONE_FS  CLONE_FILES | CLONE_SIGHAND,0);
//普通fork实现
clone (SIGCHLD,0);
//vfork实现
clone (CLONE_VFORK | CLONE_VM | SIGCHLD,0);
  • clone函数的参数含义

传递给该函数的参数标志着新创建的进程的行为方式和父进程的共享状态
在这里插入图片描述在这里插入图片描述

4.2 内核线程

  • 概述

所谓内核线程就是指独立运行在内核的标准进程在后台执行一些操作(flush、ksofirqd),一直运行在内核不会切换到用户空间,内核线程和普通进程一样,可以被调度,可以被抢占

你通过ps -ef可以看到很多内核线程,这些线程在启动时被其他内核线程创建,即内核线程只能由内核线程创建,通过从一个基内核线程kthreadd来实现
在这里插入图片描述

  • 新内核线程的创建与运行接口

线程的创建还是基于clone的调用,新的进程通过这一个函数被创建后处于不可运行状态,如果不显式调用wake_up_process()明确地唤醒它,它不会主动运行。创建一个进程并让它运行起来,可以通过调用kthread_run():该函数就是将创建和唤醒简单的封装了一下

//创建
struct task_struct *kthread_create(int (*threadfn)(void *data),
									void *data,
									const char namefmt []....)
//运行
struct task_struct *kthread_run(int (*threadfn)(void *data),
									void *data,
									const char namefmt[]...)
  • 内核线程的结束

一直运行到调用do_exit或内核其他线程调用stop函数,参数就是指定定制线程的描述符地址

int kthread_stop(struct task struct *k)

5.进程终结

  • 概述

无论怎么样进程都要终结,当一个进程终结,内核必须释放他所占的资源并把去世的消息告知父进程,这个终结行为一般由自己引发(显式或隐式),一般都是通过do_exit函数来完成

do_exit函数的执行步骤

  1. tast_strcut的标志成员设置为退出状态
  2. 调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
  3. 这里有一步通过查阅资料也不怎么懂,就是看是否开启了BSD的进程记账,然后调用相应方法进程输出记账信息(????)
  4. 然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。
  5. 接下来调用sem _exit()函数。如果进程排队等候IPC信号,它则离开队列。
  6. 调用exit_files()和exit_fis(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
  7. 接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供时圾出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
  8. 调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为进程组中的其他进程或者为init进程,并把进程状态(存放在task_struct结构的exit_state中)设成EXIT_ZOMBIE
  9. do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE 状态的进程不会再被调度,所以这是进程所执行的最后一段代码。do_exit()永不返回。

5.1 删除进程描述符

  • 概述

再让此进程成为僵尸状态后,虽然已经不能运行,但系统还保留了它的进程描述符,这样可以让系统有办法在终结状态后仍能获得它的信息,所以进程终结和进程描述符的工作是分开的,父进程获取已终结子进程的信息后,给系统说明不在关注,子进程的文件描述符才会被删除

wait族的函数

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

  • release_task函数的调用
  1. 一系列调用函数调用,从pidhash中删除该进程,然后从任务列表中也删除
  2. 调用exit_signal函数释放其资源,并进行统计和记录
  3. 如果该进程是进程组最后一个进程,且领头进程已死,则会通知领头进程的父进程
  4. 调用函数释放其内核栈和thread_info结构所占的页,并释放tast_struct所占的slab高速缓存。

5.2 孤儿进程造成的不利

  • 孤儿进程

孤儿线程,是当父进程退出而子进程还在运行,这种会等待下次的init进程给回收

  • 不利

如果父进程在子进程之前退出,那么必须有机制来保证子进程找到一个新的父进程,否则这些成为孤儿的进程就会在退出时永远是僵死的状态,会消耗内存;前面我们通过寻找同一个进程组的方式来找,如果找不到则让init进程作为他们的父进程

寻父过程

  • do_exit(中会调用exit notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来执行寻父过程:这个过程就是在寻找进程组内的其他进程,如果没有其他进程,则会最终返回init进程
  • 如果找到了养父线程,则会为这些孤儿线程设置新的父进程
  • 然后调用ptrace_exit_finish()同样进行新的寻父过程,不过这次是给ptraced 的子进程寻找父亲。

关于ptrace

ptrace 提供了一种机制使得父进程可以观察和控制子进程的执行过程,ptrace 还可以检查和修改子进程的可执行文件在内存中的image及子进程所使用的寄存器中的值。通常来说,主要用于实现对进程插入断点和跟踪子进程的系统调用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值