加入收藏 | 设为首页 | 会员中心 | 我要投稿 天瑞地安资讯网 (https://www.52baoding.com/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 服务器 > 搭建环境 > Unix > 正文

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

发布时间:2023-01-13 15:31:38 所属栏目:Unix 来源:转载
导读: 文章目录
从内核出发
为什么有了IPC(进程间通信)机制?
进程间有了通信出自于OS内核可分为两大阵营
微内核在某些OS的改良
由于IPC机制的开销要高于函数调用,并且会涉及频繁的上下文切换

文章目录

从内核出发

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

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

微内核在某些OS的改良

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

Linux与Unix的显著差异

内核开发的特点

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

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

内联函数

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

内核的内存不分页

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

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

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

进程管理 1.进程

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

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

进程的两种虚拟机制

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

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

Linux中的进程创建

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

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

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

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

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

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

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

在这里插入图片描述

2.1 分配进程描述符

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

为了避免使用额外的寄存器专门记录,现在的slab分配器都是通过动态的生成描述符,只要在栈底(向下添加)或栈顶(向上添加)创建一个新的结构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是一个short int类型的数,最大默认值为32768(但可以增加高达400万的值,这个400万就是定义的最大值),内核将PID与描述符进行捆绑

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

如何得到文件描述符:

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

2.3 进程状态

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

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

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

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

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

进程间状态转换

在这里插入图片描述

2.4 设置当前进程状态

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

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

2.5 进程上下文

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

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

2.6 进程家族树

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

每个描述符中都包含一个指向其父进程的描述符,称为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来实现

3.1 写时拷贝

传统拷贝:

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

Linux的写时拷贝

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

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

3.2 fork()函数

fork的开销

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

fork的实现

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

copy_process函数的作用

调用dup函数为进程创建一个内核栈、thread_info结构和描述符,这些值与当前进程的值相同,此时子进程和父进程完全相同检查并确保当前用户所拥有的进程数目没有超过给其分配的资源数子进程进行与父进程的分离,将子进程的描述符中的很多属性将被清零或者恢复初始化,不是继承而来的属性不做修改。但大多数据仍不被修改子进程设置状态为不可中断,保证不会接任务执行copy_process调用copy_flags更新描述符的flag属性,表示进程是否有root权力的属性被清零,表示进程还没有执行的exec函数的标志PF FORKNOEXEC被设置调用alloc_pid为新进程分配一个有效的PID根据传递给上层clone函数的参数,copy_process会拷贝或者打开或共享相应的文件、地址空间、信号等,一般情况下这么资源会被进程中的线程共享概copy_process函数做收尾并返回一个指向子进程的指针成功返回到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);

传递给该函数的参数标志着新创建的进程的行为方式和父进程的共享状态

在这里插入图片描述

在这里插入图片描述

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函数的执行步骤

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

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

wait族的函数

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

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

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

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

寻父过程:

关于ptrace

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

(编辑:天瑞地安资讯网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章